Compare commits

..

23 Commits

Author SHA1 Message Date
MarkusRe 8706d623fb Agents and scripts: Flat Pattern, Export Bend Table and Path Flange describe their exact parameters and replies (#6581) (#12545)
* claim: #6581

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

* Sheet metal: schemas for flat pattern, bend table and path flange (#6581)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:28:02 +00:00
github-actions[bot] ae1d85e5d4 Regenerate generated files and fold pending files [skip ci] 2026-11-08 07:38:32 +00:00
github-actions[bot] 0d350e7b38 Regenerate generated files and fold pending files [skip ci] 2026-11-08 07:27:50 +00:00
PeppaWutz e69dfe126c Agents and scripts: Select and Clear Selection describe their exact parameters and reply (#13132) (#13174)
* Agents and scripts: Select and Clear Selection describe their exact parameters and reply (#13132)

select.set and select.clear now publish typed input and output schemas.
Each item of select.set is read as the handler reads it today, with the
same error messages, and both replies are built from the typed value and
serialize to the same JSON as before. Their lines leave unconverted.txt.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.

* Select: the item test matches each item to the branch of its type

`conforms` doesn't check `const`, so every_selection_kind_is_taken now also
finds the SelectItem branch whose `type` const is the item's and checks the
item against it, and checks that every branch is tried.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 06:44:26 +00:00
0rdnungsamt 3c4b336080 Sketch: a preference to project references as construction geometry (#13142) (#13166)
* claim: #13142

* Sketch: a preference to project references as construction geometry (#13142)

Project, Intersect, Include 3D Geometry and the face edges a new sketch on a
face projects all came in as normal geometry, so they joined the sketch's
profiles even when they were only meant as references to draw against.

A new preference, "Project references as construction geometry" (Preferences >
Sketch, off by default), makes them start as construction geometry. They stay
linked to the model. It is the user's, like "Project face edges into new
sketches": it is kept when another design is opened or started, follows the
user across tabs and is saved with the preferences. sketch.project,
sketch.intersect and sketch.include_3d take an optional `construction`
boolean so scripts and agents can choose per call.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:34:21 +00:00
0rdnungsamt 1ec5eda955 Schemas: the rectangle and polygon sketch commands describe their parameters and replies (#13197)
* claim: #13196

* Schemas: the rectangle and polygon sketch commands describe their parameters and replies (#13196)

The seven commands sketch.rectangle.two_point, three_point, center, rounded
and sketch.polygon.inscribed, circumscribed, edge get a typed input contract
and an output schema; their replies are built from the reply types and their
lines leave schema/unconverted.txt. A test that passed the rectangle an `ids`
key it never read no longer does.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:33:30 +00:00
Sebastian 7bb016e5e4 Press Pull: the dialog the Q shortcut opens is titled "Press Pull", not "Extrude" (#11705) (#11712)
* claim: #11705

* Press Pull: the dialog the Q shortcut opens is titled PRESS PULL, not EXTRUDE (#11705)

Pressing Q (or picking Press Pull) with a planar face, a profile or
nothing selected opened the Extrude panel, so the shortcut and the
window disagreed. The panel now says PRESS PULL whenever it was opened
by Press Pull; a plain Extrude (E) still says EXTRUDE.

- lib.rs: the extrude dialog opened by solid.press_pull always carries
  the press_pull marker now (before, only with an empty selection)
- dialogs.rs: heading and issue-report title use PRESS PULL for it
- new ui.heading_press_pull message in assets/locales/pending/
- regression test in preselect_tests.rs + UI scenario pressing Q and E

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Address review on #11712: Press Pull header, attribution row, precise test assertions

- dialogs.rs: the panel body header of a Press Pull extrude uses the
  solid.press_pull command (icon and label) instead of Extrude's
- attribution/pending/press-pull-dialog-title.md: row for the pending
  locale asset
- preselect_tests.rs: assert the drawn header by accessibility role and
  place in the dialog header band, for Q and for plain E
- scenario: check the press_pull flag and the painted header label

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Fix f64/f32 in the header-label test assertion

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Pin the header-label test to the dialog header band (no role filter)

The accesskit tree carries the painted title under its own node, not
Role::Label; the header band rect is the precise anchor, so a
same-named toolbar widget cannot satisfy the assertion.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* Press Pull title test: read the drawn header from the node value as well as its label

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

---------

Co-authored-by: fork <fork@foo.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
Co-authored-by: Brennen Herbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 06:04:14 +00:00
0rdnungsamt 98fdb6ef3c Tests: shader files keep LF line ends on Windows checkouts, so the GPU volume shader test passes there (#13138) (#13244)
* claim: #13138

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

* Shaders keep LF line ends on every checkout (#13138)

Tests embed .wgsl files with include_str! and match multi-line excerpts
written as Rust strings, which are always LF. On a Windows checkout with
core.autocrlf=true the shaders came out CRLF, so
gpu_volume_signed_primary_preserves_old_perspective_lens_none_and_zero_bytes
never matched. Mark *.wgsl as text with eol=lf, like requested_icons.txt.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:54:02 +00:00
github-actions[bot] 95fece4c7b Regenerate generated files and fold pending files [skip ci] 2026-11-08 06:20:59 +00:00
1993eg1 f959c0168d Add Hole command guide (machinist perspective) (#13125)
* Add Hole command guide (machinist perspective)

User guide for the Hole command: locating holes from two edges, UNC/UNF tapped holes, tap drill readout, blind tapped holes with a separate thread depth, and drill point angles. Tested in SolidCraft v0.30.1.

* Address review: reference edges, curved faces, multiple holes, presets

Updates from the CodeRabbit review, checked against the Hole dialog in SolidCraft v0.30.1:
- Reference edges must be straight and not parallel (no opposite sides).
- Single holes can go on curved faces; edge references need a flat face.
- Multiple placement: sketch points, set Placement to Multiple, select the points.
- Tapped holes carry the thread as a callout; the thread is not cut into the wall. Points to the Thread command for real thread geometry. No Modeled control appears in the v0.30.1 Hole dialog.
- Added a row for Personal Presets (Save new, Rename, Delete).
2026-11-08 05:42:38 +00:00
Kevin Ravensberg a2d42fdaec Render preview: near-black colours match the exported image (#5761) (#13219)
* Render preview: near-black colours decode as the exported image does

The product-lights branch of the viewport shader decoded the base colour
with `to_linear`, whose dark branch returns `c` instead of `c / 12.92`,
and on a linear attachment sent its encoded result back through
`out_color`, which decoded it the same way. Colours at or below about
#0a0a0a therefore lit far brighter in the Render preview than in the CPU
image (#050505: 38/255 against 5/255).

The branch now decodes with a piecewise `srgb_to_linear`, the transfer
`job::image` uses, returns linear light directly to a linear attachment
and encodes once otherwise. `to_linear` and the Design viewport's path
are unchanged.

The test-only offscreen renderer takes the preview lights, and a new test
renders a sphere under one uniform light for black, near-blacks on both
sides of the 0.04045 knee and mid-tones, on linear and sRGB attachments,
against the CPU image's decode, tone map and encode.

* Render preview: decode with the shader's existing srgb_decode

The product-lights branch now calls `srgb_decode`, the exact piecewise
decoder the sky fill already uses, instead of an identical new
`srgb_to_linear`.
2026-11-08 05:32:34 +00:00
Nathan Dane eaf8f27a7f Panels: show real Browser, History, Assistant and Render scene content (#11309) (#13199)
* claim: #11316

* Panels: support keyboard navigation and accessible controls (#11316)

* Panels: mount existing content in the shared layout

* Panels: fix validation imports and simplify host setup

* Panels: recover legacy sizes and preserve splitter controls

* Panels: confine legacy size recovery to shared import

* Assistant: keep the composer visible in short shared panels

* Assistant: reserve picker spacing above the composer

* Keep shared Assistant top controls within composer scroll budget
2026-11-08 05:28:07 +00:00
dudedagger42 8f1143b940 Naming: a piece name finds its face once the face is whole again (#11731) (#13147)
A reference picked while a face was split keeps the piece's qualifier
(F4:side:a1#[F4:start]). When that face is one face again, the whole face
still bounded by the faces the qualifier names now matches as the piece, so
the projected edge between it and F4:start is found. Numbered pieces stay
strict.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:22:09 +00:00
dudedagger42 778e7465e9 Circular Pattern of a cut works with another component active (#12996) (#13145)
* Add failing test: pattern of an untargeted cut with another component active (#12996)

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

* Pattern of an untargeted cut or intersect works on its source's component (#12996)

The copies took the bodies of the component active when the pattern was made,
so with another component active the pattern said there was no body to cut.

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

* Pattern: source-component scope only when the pattern's component has no body; also for misses and sheet cuts (#12996)

- The copies of an untargeted cut or intersect take the source's component only when the
  pattern's own component has no body (and no participants): a pattern that cut bodies of its own
  component before still does, so saved designs rebuild the same.
- That scope is chosen before the copy-miss count and the sheet-cut step, so all three use it.
- Test: pattern_of_an_untargeted_cut_still_cuts_its_own_components_body.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 05:21:01 +00:00
PeppaWutz fa127b3e29 Agents and scripts: the feature folder commands, Reorder, Move Timeline Items and Edit Feature describe their exact parameters and reply (#13131) (#13180)
timeline.group, ungroup, move_to_group, rename_group, reorder, reorder_many,
move_many and redefine now publish typed input and output schemas, so agents
and scripts can read exactly which parameters they take and what they return,
and the warn and strict parameter checks cover them.

Each parameter is read the way its handler reads it today, with the same error
messages, and the replies are built from the typed values and serialize to the
same JSON as before. Their lines leave unconverted.txt. Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:20:05 +00:00
CamWight 970eb715f3 Control channel: each step of engine.script gets the window's follow-up, like engine.batch (#12629) (#12630)
* claim: #12629

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

* Control channel: each step of engine.script runs as engine.execute runs it (#12629)

A script whose steps were all document commands ran them on the engine session alone, so the
window's part of each command was skipped: after `view.grid {"on": false}` the grid stayed on, and
the tool, the spin, the layout, preferences such as View Angles, and the first-body fit were missed
too. Now every step goes through `control::execute`, as each step of `engine.batch` does. The first
failing step still stops the script and the steps before it stay, with the same error words; a
script with a panel command answers as before.

The script's shape and its 100 000-step cap are read by one function, `script_steps`, for the
engine's own scripts and the window's.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:37 +00:00
Kai Evans a74e78fba8 Chamfer and fillet: an edge on a moved part is found where you picked it (#13239)
A chamfer or fillet without `body` on a moved part failed with
"[20, 0, 0] is where 2 edges meet". This happened when another part
had an edge at that point in its own frame. Take 2 parts that meet
in the world, but not in their own frames. A pick on the shared edge
cut the first part. edge_points looked for edges on every body in
that body's own frame. It ignored the world point of the pick.

- frames.rs remembers the world point of every pick, face or not.
  picked_world(local, nth) returns it. nth keeps 2 picks at one local
  point apart.
- edge_points maps the world point into each placed body's frame,
  hidden placements too. The world rows are built on first use. A
  body's box, grown by the reach, culls it. The polylines short-list
  edges. The exact curves decide (edge_foot: 33 samples, then a
  golden-section search). Bodies within 2 d + 1e-3 mm of the nearest
  refuse the pick. The reply names both: "[20, 0, 0] lies on edges of
  2 bodies, Base block and Cap block: name the one to chamfer with
  `body`". A hidden body adds "(X is hidden)".
- A seam at the pick, with no edge there, adds no second body.
- 2 placements of one body at one edge, or one local point, are 1 edge.
- With no placement to look through, the pick uses the bodies' own
  frames. That happens when every frame is singular. The pick is not
  passed unchecked.
- A pick's own `body` must exist and agree with `body`. Else the
  command refuses: "an edge names `Cap block`, but `body` is `Base
  block`: give one". Main finds the pick on Cap and cuts Base.
- The corner check runs on the body found. Corner and no-edge
  refusals print the world point.
- With 2 or more bodies, the feature keeps the body found as `body`.
  Picks on 2 or more bodies get 1 more check. If another
  body's frame has an edge at a pick's point, the command refuses. The
  reply asks for "each body in a command of its own". The ghost check
  skips edges whose polyline is too far before it runs edge_foot.
- solid.press_pull forwards `body` to the fillet. Its frames entry
  maps `body`, and its params name it.
- The fillet schema and the fillet and chamfer params describe the
  lookup.

Changes a default: no. Calls that cut the wrong part now refuse,
naming both parts. So do picks on the cut of a split body. A pick
whose own `body` disagrees with `body` refuses too. Dialogs already
send `body`.

Synthetic scripts ran against 6a1fe7468 (solidcraft-cli run,
--profile ci). 4 picks now cut the picked part, where main refused:
- Base's edge, with Cap moved 50 mm up.
- The same, with the root active and Cap moved 500 mm along x.
- Cap's edge at its world point.
- Base's edge with tangent_chain false.
Parts meeting in one frame or in the world are refused, naming
both. Press Pull there is refused too; main rounds Base block. A box
edge on a cylinder's seam is cut; main tries the seam and fails.

Tests: 19 in frames_tests.rs (16) and tests.rs (3). 16 fail on
6a1fe7468. 3 pass there: hostile picks, a singular placement, and 2
placements of one part. action_matrix
edge_round_actions_keep_the_picked_body expects the new refusal.

cargo xtask ci: 13948 passed, 32 failed, 67 ignored, 89 binaries.
- 22 failures are in tests/KNOWN_FAILING_TESTS.
- 7 fail alone on 6a1fe7468 too:
  3 pleat tests (FilletRearOuter, SMLib status 1001),
  smlib a_cylinder_side_section_is_its_circle,
  gpu gpu_thin_lens_focus_and_defocus_match_geometric_oracle,
  ui-egui the_extrude_arrow_passes_faces_beside_the_profile_and_in_a_free_drag,
  kernel boolean_props translation_invariant (a random 2-sphere case).
- 3 ui-egui agent_chat tests pass alone on both trees.
2026-11-08 05:19:04 +00:00
0rdnungsamt 2171393816 Section Analysis: the ground shadow stays soft instead of ending in a hard line at the section plane (#10708) (#12959)
* claim: #10708

* Section Analysis: the ground shadow is no longer cut by the section plane (#10708)

The ground shadow was drawn with the see-through pipeline, whose fragment
shader discards everything past the section plane, so a section ended the
soft shadow in a hard straight line. It now uses the pipeline reference
planes already use, the same blend and depth test without the cut.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:04 +00:00
PeppaWutz 3eacb3722e Agents and scripts: the occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and Paste New describe their exact parameters and reply (#13186) (#13231)
* Agents and scripts: the occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and Paste New describe their exact parameters and reply (#13186)

component.capture_position, component.revert_position, component.move_bodies,
component.move_sketches, component.paste_new, occurrence.copy, occurrence.drag,
occurrence.ground, occurrence.move and occurrence.properties now publish typed
input and output schemas. Each parameter is read the way its handler reads it
today and refused with the handler's own message, keys a handler reads only in
some cases (for example component with occurrence, tolerance with matrix) are
checked only in those cases, and the replies are built from the same types the
output schemas describe, so the JSON they return does not change. Their lines
leave unconverted.txt.

The base_transform check of occurrence.move is split out of the handler, and
move_vector and the matrix readers are made visible to the schema module, so
the contract uses the handler's own readers without changing what they accept.

Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.

* Schema: base_transform is 4 columns of 4 numbers, as the handler reads it

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 05:19:02 +00:00
PeppaWutz 2c82e702f5 Agents and scripts: Joint Origin, Rigid Group, Reach Point, Motion Link, Gear, Rack and Pinion, Screw and Cam describe their exact parameters and reply (#13185) (#13242)
joint.origin, joint.rigid_group, joint.reach, joint.motion_link, joint.gear,
joint.rack_pinion, joint.screw and joint.cam now publish typed input and
output schemas. Each parameter is read the way its handler reads it today and
refused with the handler's own message. Keys that a handler reads only in some
cases (for example a joint origin's end, or a screw's a and b) are read only in
those cases. The replies are built from the same types the output schemas
describe, so the JSON they return does not change. Their lines leave
unconverted.txt. Part of #2829.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:18:21 +00:00
PeppaWutz f256bef34b Agents and scripts: Create Sketch, Normal/Construction, Delete, Drag, Move Point, Move Dimension Text, Sketch Options, Chain and Solve describe their exact parameters and reply (#13224) (#13273)
sketch.create, sketch.construction, sketch.delete, sketch.move_point,
sketch.drag, sketch.drag_end, sketch.dimension_text, sketch.options,
sketch.chain and sketch.solve now publish typed input and output schemas.
Each parameter is read the way its handler reads it today, in the cases it
reads it, and refused with the handler's own message. Each reply is built
from the typed value and serializes to the same JSON as before. Their lines
leave unconverted.txt.

Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor.
2026-11-08 05:18:14 +00:00
ABDULLAH 83cb509f81 Arabic: tanween in the canvas lock message and path tracing help, sketch term in the double Esc preference and Concentric help, timeline term in the Features under bodies note, Group holes preference wording (#13123)
* Arabic: tanween in the canvas lock message and path tracing help, sketch term in the double Esc preference and Concentric help

* Arabic: timeline term in the Features under bodies note

* Arabic: created-holes wording and collapsed-folder term in the Group holes preference
2026-11-08 05:17:41 +00:00
PeppaWutz c18aaf7cc3 Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508) (#12601)
* Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508)

sketch.insert_dxf, sketch.insert_svg and sketch.export_dxf now publish typed
input and output schemas. Each parameter is read the way its handler reads
it today and refused with the handler's own message, and the replies are
built from the same types the output schemas describe, so the JSON they
return does not change. Keys the handler reads only in some cases (a path
next to text, a height next to a width, a scale next to either) are read by
the check in the same cases only. Their lines leave unconverted.txt.

Part of #2829.

* Insert DXF and SVG: the schema refuses a blank or too-long path when it is read

* Insert DXF and SVG: the schema checks height, scale and path only when they are read

The published schema constrained `height` next to a `width`, `scale` next to
either, and `path` next to a `text`, which the handler never reads. Their shapes
now apply only in the cases the handler reads them, matching `leave_unread`.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 02:38:51 +00:00
467 changed files with 21384 additions and 9442 deletions
+3
View File
@@ -1,6 +1,9 @@
# Preserve canonical source receipts and the runtime icon-discovery snapshot.
.gitattributes text eol=lf
/assets/icons/ui/design/requested_icons.txt text eol=lf
# Shaders reach the code through include_str!, and tests match multi-line WGSL excerpts
# written as Rust strings (always LF), so a CRLF checkout must not change them (#13138).
*.wgsl text eol=lf
# SolidCraft designs (.sc): a JSON document or a ZIP container, never Scala source, so they
# stay out of GitHub's language statistics. Git's own text/binary detection handles both kinds.
*.sc -linguist-detectable
+2
View File
@@ -201,6 +201,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-dark.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/gruvbox-light.json | SolidCraft contributors | original SolidCraft theme file mapping SolidCraft's UI colours to the Gruvbox palette's colour values (Gruvbox by Pavel Pertsev, https://github.com/morhetz/gruvbox, MIT) | MIT OR Apache-2.0 |
| assets/styles/ui/themes/solidcraft-95.json | SolidCraft contributors | original SolidCraft theme file | MIT OR Apache-2.0 |
@@ -842,6 +843,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
| assets/locales/pending/press-pull-dialog-title.json | SolidCraft contributors | Original Press Pull dialog title translations for SolidCraft's English catalog; no external text used | MIT OR Apache-2.0 |
## Code adapted from sibling projects
Generated
+2 -43
View File
@@ -4096,9 +4096,7 @@ dependencies = [
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-theme",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"ureq",
"windows-sys 0.61.2",
"winit",
@@ -4125,10 +4123,6 @@ dependencies = [
"solidcraft-mcp",
]
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.3"
@@ -4147,7 +4141,6 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4180,7 +4173,6 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
@@ -4215,7 +4207,6 @@ dependencies = [
"serde",
"serde_json",
"solidcore-record",
"solidcraft-color",
"solidcraft-engine",
"solidcraft-geom",
"solidcraft-io",
@@ -4243,7 +4234,6 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
@@ -4303,7 +4293,6 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-geom",
"web-time",
"weezl",
@@ -4356,16 +4345,6 @@ dependencies = [
name = "solidcraft-testkit"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"serde",
"serde_json",
"solidcraft-color",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.3"
@@ -4384,39 +4363,20 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-color",
"solidcraft-engine",
"solidcraft-gpu",
"solidcraft-space-mouse",
"solidcraft-theme",
"solidcraft-ui-registry",
"solidcraft-ui-text",
"syn 2.0.119",
"sys-locale",
"tiny-skia",
"toml",
"ttf-parser",
"unicode-bidi",
"web-time",
"winreg",
"zip",
]
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde_json",
"sys-locale",
"unicode-bidi",
]
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.3"
@@ -4429,7 +4389,6 @@ dependencies = [
"serde_json",
"solidcraft-engine",
"solidcraft-ui-egui",
"solidcraft-ui-text",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
-4
View File
@@ -28,10 +28,6 @@ solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-ui-registry = { path = "crates/ui-registry" }
solidcraft-color = { path = "crates/color" }
solidcraft-ui-text = { path = "crates/ui-text" }
solidcraft-theme = { path = "crates/theme" }
solidcraft-space-mouse = { path = "crates/space-mouse" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
+1 -1
View File
@@ -1308,7 +1308,7 @@ fn edge_round_actions_keep_the_picked_body() {
let mut unscoped = p.clone();
unscoped.as_object_mut().unwrap().remove("body");
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
assert!(err.contains("edges meet"), "{err}");
assert!(err.contains("[10, 0, 20] lies on edges of 2 bodies, Plate and Block"), "{err}");
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
tried += 1;
}
-1
View File
@@ -23,7 +23,6 @@ profile = ["solidcraft-engine/profile"]
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+4 -4
View File
@@ -48,15 +48,15 @@ impl Download {
}
if solidcraft_ui_egui::documents::any_dirty(app) {
let mut answer = None;
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_text::tr("web.open_link"))
let closed = ui_kit::Window::new(egui::Id::unique("open_link"), "status_warning", solidcraft_ui_egui::i18n::tr("web.open_link"))
.required()
.show_body(ctx, |ui| {
ui.label(solidcraft_ui_text::tr("web.open_link_confirm"));
ui.label(solidcraft_ui_egui::i18n::tr("web.open_link_confirm"));
ui.horizontal(|ui| {
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.open"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.open"), ui_kit::Button::Normal).clicked() {
answer = Some(true);
}
if ui_kit::button(ui, solidcraft_ui_text::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
answer = Some(false);
}
});
+8 -9
View File
@@ -810,7 +810,7 @@ mod web {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -864,7 +864,7 @@ mod web {
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.backing_on_hover_text(solidcraft_ui_text::tr("web.files.insert_stored_drawings_hint"))
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
@@ -1016,7 +1016,7 @@ mod web {
/// Whether the shell already has the on-demand face the live language needs.
fn script_font_ready(&self) -> bool {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_text::effective_language(&self.app.preferences.language)) {
match solidcraft_ui_egui::fonts::web_script_font_kind(solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language)) {
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
Some("ja") => self.cjk_bytes.is_some(),
@@ -1058,7 +1058,7 @@ mod web {
false
}
};
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if !installed && solidcraft_ui_egui::fonts::needs_cjk_font(effective) {
log::warn!("script font unavailable; showing English for this session");
}
@@ -1067,13 +1067,13 @@ mod web {
ctx.request_repaint();
}
let ready = self.script_font_ready();
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if self.font.tick(&mut self.app.preferences.language, ready) {
self.fetch_font(ctx, solidcraft_ui_egui::fonts::web_script_font_kind(effective).unwrap_or("ja"));
}
// The CJK face leads only a CJK UI: its Cyrillic glyphs are full-width, so switching
// to Ukrainian (or English) must put the Latin face back in front.
if solidcraft_ui_text::effective_language(&self.app.preferences.language) != self.font_language {
if solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language) != self.font_language {
self.apply_fonts(ctx);
}
}
@@ -1081,7 +1081,7 @@ mod web {
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
fn apply_fonts(&mut self, ctx: &egui::Context) {
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
let effective = solidcraft_ui_egui::i18n::effective_language(&self.app.preferences.language);
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
solidcraft_ui_egui::fonts::setup_fonts(ctx);
} else {
@@ -1149,7 +1149,7 @@ mod web {
UNSAVED.with(|u| u.set(self.asks_before_unload()));
if self.launch.embed {
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
if ui_kit::button(ui, solidcraft_ui_text::tr("web.open_full"), ui_kit::Button::Normal).clicked()
if ui_kit::button(ui, solidcraft_ui_egui::i18n::tr("web.open_full"), ui_kit::Button::Normal).clicked()
&& let (Some(window), Some(url)) = (web_sys::window(), &self.full_url)
{
let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener");
@@ -1225,7 +1225,6 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
solidcraft_ui_egui::i18n::install();
wasm_bindgen_futures::spawn_local(async {
let Some(window) = web_sys::window() else { return };
let launch = crate::Launch::parse(&query());
-2
View File
@@ -34,8 +34,6 @@ solidcraft-engine = { workspace = true }
# The D3D12 shader compiler the window starts with (#6756).
solidcraft-gpu = { workspace = true }
solidcraft-ui-egui = { workspace = true }
solidcraft-ui-text = { workspace = true }
solidcraft-theme = { workspace = true }
solidcraft-space-mouse = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
+2 -3
View File
@@ -468,7 +468,7 @@ fn case_variants(exts: &[&str]) -> Vec<String> {
fn content_ui(app: &mut SolveApp, ui: &mut egui::Ui) {
let content = ui.ctx().content_rect();
let above = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), content.top()));
ui.painter().rect_filled(above, 0.0, solidcraft_theme::Tokens::get().app_bar);
ui.painter().rect_filled(above, 0.0, solidcraft_ui_egui::theme::Tokens::get().app_bar);
ui.scope_builder(egui::UiBuilder::new().max_rect(content), |ui| app.ui(ui));
}
@@ -518,7 +518,7 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
// The groups come from the format registry, so a new format joins this dialog by itself.
let mut everything = io::formats::open_extensions(true);
everything.extend(["dxf", "svg"]);
dialog = dialog.add_filter(solidcraft_ui_text::tr("file.open.drawing_filter"), &case_variants(&everything));
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
@@ -945,7 +945,6 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
mark("main");
solidcraft_ui_egui::i18n::install();
// On OCLP-patched Macs, wgpu picks a Metal Shading Language standard that the system's
// compiler refuses (`invalid value 'metal3.2'`), because the patched OS reports a newer
// macOS version than the GPU's Metal compiler supports. Re-launching with
+1
View File
@@ -18,6 +18,7 @@ assets/
ui/source/ # Rust viewport glyphs, markers and navigation
branding/web/source/og.png # shared social preview card
branding/logo/ # print/web logo files; guide in branding/README.md
styles/ui/palettes.rs # light/dark defaults
styles/web/loading.css # web loading/canvas styles
appearances/library.rs # built-in visual presets
materials/library.rs # physical defaults and colours
+1 -1
View File
@@ -38,7 +38,7 @@ Other colours, all already used in the project:
| Colour | Hex | Where it comes from |
|---|---|---|
| Wordmark blue | `#15499e` | loading logo `--word` |
| Interface accent | `#2678da` | light theme `accent` token (`crates/theme/src/palettes.rs`) |
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
| Dark background | `#1f232b` | dark theme `file_background` token |
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
+1 -1
View File
@@ -81,7 +81,7 @@ for message in icon_palette.validation_errors(pal, names | set(al)):
err(d + '/palette.toml', message)
# the review page lies its icons on the real app surfaces: its colours must be the app's tokens
def app_tokens():
src = open(os.path.join(d, '..', '..', '..', '..', 'crates', 'theme', 'src', 'palettes.rs'), encoding='utf-8').read()
src = open(os.path.join(d, '..', '..', '..', 'styles', 'ui', 'palettes.rs'), encoding='utf-8').read()
lo, hi = src.index('pub const fn light'), src.index('pub const fn dark')
light_src, dark_src = src[lo:hi], src[hi:]
def pick(block, name, dark):
@@ -1,4 +1,4 @@
/* App surfaces (keep in sync with crates/theme/src/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
/* App surfaces (keep in sync with assets/styles/ui/palettes.rs; lint_icons.py checks): --card is the ribbon surface `toolbar` the icons lie on, --bg the page (`panel` in light, `viewport_top` in dark), --line `border`, --fg `text`, --mut `text_dim`. */
:root{--bg:#fafbfc;--fg:#262a32;--card:#f6f7f9;--line:#d0d4dc;--mut:#6e7480;--sz:48px}
body.dark{--bg:#2e3440;--fg:#dee2e8;--card:#272b34;--line:#3f4552;--mut:#949ba8}
body{font:12px system-ui,sans-serif;margin:0;background:var(--bg);color:var(--fg)}
+2 -2
View File
@@ -110,7 +110,7 @@ pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
}
pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
brand_mark_styled(p, c, 20.0, solidcraft_theme::Tokens::get().logo, true);
brand_mark_styled(p, c, 20.0, crate::theme::Tokens::get().logo, true);
}
/// The same original mark as a single quiet silhouette, without face seams or outlines.
@@ -127,7 +127,7 @@ fn brand_mark_styled(p: &egui::Painter, c: Pos2, height: f32, colours: [Color32;
};
// The faces are separated by gaps that show the application bar, not by white lines.
let [light, mid, dark] = colours;
let edge_col = solidcraft_theme::Tokens::get().app_bar;
let edge_col = crate::theme::Tokens::get().app_bar;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
+2 -2
View File
@@ -1,7 +1,7 @@
//! Constraint, assembly snap, hole placement and dimension marker artwork.
use crate::dialogs_assembly::SnapKind;
use crate::theme::Tokens;
use egui::{Color32, Pos2, Stroke, vec2};
use solidcraft_theme::Tokens;
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
@@ -49,7 +49,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
// What it snaps to (hovered), or which component it is (picked): a small label.
if let Some(tag) = tag {
let t = Tokens::get();
let font = egui::FontId::proportional(solidcraft_theme::Tokens::get().font_caption);
let font = egui::FontId::proportional(crate::theme::Tokens::get().font_caption);
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
let at = c + vec2(12.0, 10.0);
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
+2 -2
View File
@@ -1,8 +1,8 @@
//! Navigation drawing definitions. Camera projection and picking remain in the UI widgets.
use crate::theme::Tokens;
use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, pos2, vec2};
use solidcore_record::Vec3;
use solidcraft_engine::geom::Ring;
use solidcraft_theme::Tokens;
pub(crate) struct Facet {
pub(crate) direction: Vec3,
pub(crate) points: Ring<Pos2>,
@@ -196,7 +196,7 @@ pub(crate) fn axis_tip_scaled(painter: &egui::Painter, c: Pos2, name: &str, d: &
// On a hovered (white) disc the letter takes the axis colour.
let letter = if hovered { col } else { t.on_accent };
let label = name.to_uppercase();
let galley = painter.layout_no_wrap(label, crate::fonts::bold(solidcraft_theme::Tokens::get().font_compass * scale), letter);
let galley = painter.layout_no_wrap(label, crate::fonts::bold(crate::theme::Tokens::get().font_compass * scale), letter);
let origin = at - galley.size() / 2.0;
let ppp = painter.ctx().pixels_per_point().max(0.5);
let origin = pos2((origin.x * ppp).round() / ppp, (origin.y * ppp).round() / ppp);
+3 -3
View File
@@ -2,16 +2,16 @@
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
pub(crate) fn agent() -> Color32 {
solidcraft_theme::Tokens::get().icons.agent
crate::theme::Tokens::get().icons.agent
}
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
let ic = solidcraft_theme::Tokens::get().icons;
let ic = crate::theme::Tokens::get().icons;
// An arrow pointer, tip at `p`.
let pts: Vec<Pos2> =
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(solidcraft_theme::Tokens::get().font_small), ic.highlight);
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(crate::theme::Tokens::get().font_small), ic.highlight);
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
painter.rect_filled(r, 4.0, ic.agent);
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
+22 -8
View File
@@ -977,7 +977,7 @@
"browser.calibrate_click_a": "انقر على الصورة لتحديد النقطة A، أو اكتب إحداثياتها.",
"browser.calibrate_click_b": "انقر على الصورة لتحديد النقطة B، أو اكتب إحداثياتها.",
"browser.calibrate_title": "معايرة: {name}",
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولاً.",
"browser.canvas_locked": "اللوحة مقفلة. ألغِ قفلها أولًا.",
"browser.capture_many": "تم تحريك {count} مكوّنات دون تثبيت مواضعها.",
"browser.capture_note": "التثبيت يحتفظ بالموضع الجديد، والإرجاع يعيده كما كان.",
"browser.capture_one": "تم تحريك مكوّن دون تثبيت موضعه.",
@@ -1236,8 +1236,11 @@
"cmd.panel.dismiss": "إغلاق العرض المؤقت للوحة",
"cmd.panel.float": "تعويم اللوحة",
"cmd.panel.focus": "تركيز اللوحة",
"cmd.panel.focus.next": "التركيز على اللوحة التالية",
"cmd.panel.focus.previous": "التركيز على اللوحة السابقة",
"cmd.panel.list": "سرد اللوحات",
"cmd.panel.move": "نقل اللوحة",
"cmd.panel.move.next_zone": "نقل اللوحة إلى المنطقة التالية",
"cmd.panel.native_close": "إعادة إرساء قسم اللوحة من نافذته المستقلة",
"cmd.panel.open": "فتح اللوحة",
"cmd.panel.peek": "عرض اللوحة المطوية مؤقتًا",
@@ -1245,6 +1248,12 @@
"cmd.panel.reset_pair": "إعادة ضبط فاصل اللوحة",
"cmd.panel.resize": "تغيير حجم قسم اللوحة",
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
"cmd.parameters_add": "إضافة معامل مستخدم",
"cmd.parameters_change": "تغيير المعاملات",
"cmd.parameters_comment": "التعليق على المعامل",
@@ -1865,7 +1874,7 @@
"help.sketch.constrainer": "يطبق القيد الذي يكاد منحنى أو منحنيان محددان يحققانه، مثل الأفقي أو التوازي أو التماس أو التساوي. استخدمه لتهذيب رسم يدوي علاقة بعد أخرى.",
"help.sketch.constraint.coincident": "يجعل نقطة تقع على نقطة أخرى أو على منحنى أو على محور الرسم. استخدمه لسد الفجوات في المقطع أو لوضع نقطة على خط.",
"help.sketch.constraint.collinear": "يجعل خطين يقعان على خط مستقيم واحد. استخدمه لمحاذاة الحواف المتقطعة، مثل جانبي حز.",
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم التخطيطي، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
"help.sketch.constraint.concentric": "يجعل دائرتين أو قوسين يشتركان في المركز نفسه، أو يضع مركز دائرة أو قوس على نقطة في الرسم، بما في ذلك نقطة الأصل. اختر العنصرين بأي ترتيب.",
"help.sketch.constraint.curvature": "يجعل منحنيين يلتقيان عند نقطة نهاية يتابعان بالانحناء نفسه (G2). استخدمه حيث لا يكفي الوصل المماسي نعومة، مثلًا على الأسطح الظاهرة.",
"help.sketch.constraint.equal": "يجعل الخطوط بالطول نفسه، أو الدوائر والأقواس بنصف القطر نفسه. استخدمه ليتحكم بُعد واحد في عدة ميزات متماثلة.",
"help.sketch.constraint.fix": "يثبّت النقاط أو المنحنيات في مكانها، أو يفك تثبيتها. استخدمه للهندسة التي يجب ألا تتحرك عند سحب الرسم أو حله.",
@@ -2520,7 +2529,7 @@
"prefs.bottom_right": "أسفل اليمين",
"prefs.browser": "المستعرض",
"prefs.browser_body_features": "الميزات تحت الأجسام",
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات المخطط الزمني التي بنته، بترتيب المخطط الزمني.",
"prefs.browser_body_features_note": "يمكن فتح كل جسم في المستعرض لعرض ميزات الخط الزمني التي بنته، بترتيب الخط الزمني.",
"prefs.browser_components_open": "المكوّنات مفتوحة من البداية",
"prefs.browser_components_open_note": "يبدأ كل مكوّن في المستعرض مفتوحًا كما كان من قبل، بدلًا من أن يبقى مغلقًا حتى تفتحه.",
"prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي",
@@ -2561,8 +2570,8 @@
"prefs.design_tools.degrees": "درجات",
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
"prefs.design_tools.hole_group": "تجميع الثقوب المصنوعة معًا",
"prefs.design_tools.hole_group_help": "الثقوب المصنوعة دفعة واحدة تشترك في مجلد في السجل، مغلق في البداية؛ افتحه لتحرير كل ثقب على حدة.",
"prefs.design_tools.hole_group": "تجميع الثقوب المُنشأة معًا",
"prefs.design_tools.hole_group_help": "الثقوب المُنشأة دفعة واحدة تشترك في مجلد واحد في السجل يبدأ مطويًا؛ افتحه لتحرير كل ثقب على حدة.",
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
@@ -2842,6 +2851,8 @@
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
"prefs.selecting": "التحديد",
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
@@ -2864,8 +2875,8 @@
"prefs.sketch.drawing": "الرسم",
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
"prefs.sketch.escape_twice_finishes": "الضغط المزدوج على Esc ينهي الرسم التخطيطي",
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم التخطيطي. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم التخطيطي.",
"prefs.sketch.escape_twice_finishes": "الضغط على Esc مرتين ينهي الرسم",
"prefs.sketch.escape_twice_finishes_note": "ضغطتان سريعتان على Esc تنهيان الرسم. عند الإيقاف: يلغي Esc الأداة أو التحديد فقط، وينهي أمر «إنهاء الرسم» الرسم.",
"prefs.sketch.exit_keep": "إبقاء",
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
@@ -3386,7 +3397,7 @@
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
"render.cpu_path_depth": "عمق المسار",
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_path_lighting": "إضاءة غير مباشرة",
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
"render.cpu_point": "ضوء نقطي",
@@ -5532,6 +5543,7 @@
"ui.heading_plastic": "البلاستيك",
"ui.heading_position": "الموضع",
"ui.heading_prepare": "تحضير",
"ui.heading_press_pull": "ضغط وسحب",
"ui.heading_rectangular_pattern": "نمط مستطيلي",
"ui.heading_revolve": "دوران",
"ui.heading_right": "يمين",
@@ -5875,6 +5887,8 @@
"ui.profiles": "المقاطع",
"ui.project": "إسقاط",
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
"ui.properties": "الخصائص",
"ui.properties.copy_matrix": "نسخ المصفوفة",
"ui.properties.first_moments": "العزوم الأولى",
+14
View File
@@ -1047,8 +1047,11 @@
"cmd.panel.dismiss": "Panel-Vorschau schließen",
"cmd.panel.float": "Panel frei platzieren",
"cmd.panel.focus": "Panel fokussieren",
"cmd.panel.focus.next": "Nächstes Panel fokussieren",
"cmd.panel.focus.previous": "Vorheriges Panel fokussieren",
"cmd.panel.list": "Panels auflisten",
"cmd.panel.move": "Panel verschieben",
"cmd.panel.move.next_zone": "Panel in nächste Zone verschieben",
"cmd.panel.native_close": "Nativen Panel-Bereich wieder andocken",
"cmd.panel.open": "Panel öffnen",
"cmd.panel.peek": "Eingeklapptes Panel anzeigen",
@@ -1056,6 +1059,12 @@
"cmd.panel.reset_pair": "Panel-Trennlinie zurücksetzen",
"cmd.panel.resize": "Panel-Bereich skalieren",
"cmd.panel.resize_pair": "Panel-Trennlinie verschieben",
"cmd.panel.sash.down": "Panel-Trennlinie nach unten verschieben",
"cmd.panel.sash.left": "Panel-Trennlinie nach links verschieben",
"cmd.panel.sash.reset": "Trennlinie des fokussierten Panels zurücksetzen",
"cmd.panel.sash.right": "Panel-Trennlinie nach rechts verschieben",
"cmd.panel.sash.up": "Panel-Trennlinie nach oben verschieben",
"cmd.panel.toggle": "Fokussiertes Panel ein- oder ausklappen",
"cmd.parameters_add": "Benutzerparameter hinzufügen",
"cmd.parameters_change": "Parameter ändern",
"cmd.parameters_comment": "Parameter kommentieren",
@@ -2602,6 +2611,8 @@
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
"prefs.selecting": "Auswahl",
"prefs.shared_panel_layout": "Gemeinsames Panellayout verwenden",
"prefs.shared_panel_layout_help": "Browser, Verlauf und Assistent in verschiebbaren Panels anzeigen. Die Änderung wird beim Anwenden der Einstellungen wirksam.",
"prefs.shift": "Umschalt",
"prefs.shift_add_ctrl_toggle": "Umschalt fügt nur hinzu; Strg / Cmd schaltet die Auswahl um",
"prefs.shift_add_note": "Aktiviert: Umschalt fügt Elemente zur Auswahl hinzu, Strg / Cmd schaltet ihre Auswahl um. Deaktiviert (Standard): Beide Tasten schalten die Auswahl um.",
@@ -5252,6 +5263,7 @@
"ui.heading_plastic": "PLASTIK",
"ui.heading_position": "POSITION",
"ui.heading_prepare": "VORBEREITEN",
"ui.heading_press_pull": "DRÜCKEN/ZIEHEN",
"ui.heading_rectangular_pattern": "RECHTECKMUSTER",
"ui.heading_revolve": "DREHEN",
"ui.heading_right": "RECHTS",
@@ -5591,6 +5603,8 @@
"ui.profiles": "Profile",
"ui.project": "Projizieren",
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
"ui.project_references_construction": "Referenzen als Konstruktionsgeometrie projizieren",
"ui.project_references_construction_note": "Projizieren, Schneiden, 3D-Geometrie einbeziehen und die Flächenkanten einer neuen Skizze auf einer Fläche erzeugen Konstruktionsgeometrie: Bezüge zum Zeichnen, die nicht zu Profilen gehören. Sie bleiben mit dem Modell verknüpft.",
"ui.properties": "Eigenschaften",
"ui.properties.copy_matrix": "Matrix kopieren",
"ui.properties.first_moments": "Statische Momente",
+14
View File
@@ -1236,8 +1236,11 @@
"cmd.panel.dismiss": "Dismiss Panel Peek",
"cmd.panel.float": "Float Panel",
"cmd.panel.focus": "Focus Panel",
"cmd.panel.focus.next": "Focus Next Panel",
"cmd.panel.focus.previous": "Focus Previous Panel",
"cmd.panel.list": "List Panels",
"cmd.panel.move": "Move Panel",
"cmd.panel.move.next_zone": "Move Panel to Next Zone",
"cmd.panel.native_close": "Redock Native Panel Section",
"cmd.panel.open": "Open Panel",
"cmd.panel.peek": "Peek Collapsed Panel",
@@ -1245,6 +1248,12 @@
"cmd.panel.reset_pair": "Reset Panel Sash",
"cmd.panel.resize": "Resize Panel Section",
"cmd.panel.resize_pair": "Resize Panel Sash",
"cmd.panel.sash.down": "Resize Panel Sash Down",
"cmd.panel.sash.left": "Resize Panel Sash Left",
"cmd.panel.sash.reset": "Reset Focused Panel Sash",
"cmd.panel.sash.right": "Resize Panel Sash Right",
"cmd.panel.sash.up": "Resize Panel Sash Up",
"cmd.panel.toggle": "Toggle Focused Panel",
"cmd.parameters_add": "Add User Parameter",
"cmd.parameters_change": "Change Parameters",
"cmd.parameters_comment": "Comment Parameter",
@@ -2842,6 +2851,8 @@
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
"prefs.selecting": "Selecting",
"prefs.shared_panel_layout": "Use shared panel layout",
"prefs.shared_panel_layout_help": "Show the existing Browser, History and Assistant in movable panels. The change takes effect when preferences are applied.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
@@ -5532,6 +5543,7 @@
"ui.heading_plastic": "PLASTIC",
"ui.heading_position": "POSITION",
"ui.heading_prepare": "PREPARE",
"ui.heading_press_pull": "PRESS PULL",
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
"ui.heading_revolve": "REVOLVE",
"ui.heading_right": "RIGHT",
@@ -5875,6 +5887,8 @@
"ui.profiles": "Profiles",
"ui.project": "Project",
"ui.project_face_edges_sketch": "Project face edges into new sketches",
"ui.project_references_construction": "Project references as construction geometry",
"ui.project_references_construction_note": "Project, Intersect, Include 3D Geometry and the face edges of a new sketch on a face make construction geometry: references to draw against, left out of profiles. They stay linked to the model.",
"ui.properties": "Properties",
"ui.properties.copy_matrix": "Copy matrix",
"ui.properties.first_moments": "First moments",
+14
View File
@@ -1236,8 +1236,11 @@
"cmd.panel.dismiss": "סגירת תצוגה זמנית של חלונית",
"cmd.panel.float": "הצפת חלונית",
"cmd.panel.focus": "מיקוד חלונית",
"cmd.panel.focus.next": "מיקוד בלוח הבא",
"cmd.panel.focus.previous": "מיקוד בלוח הקודם",
"cmd.panel.list": "הצגת רשימת חלוניות",
"cmd.panel.move": "העברת חלונית",
"cmd.panel.move.next_zone": "העברת הלוח לאזור הבא",
"cmd.panel.native_close": "עיגון מחדש של מקטע חלונית מחלון נפרד",
"cmd.panel.open": "פתיחת חלונית",
"cmd.panel.peek": "הצגה זמנית של חלונית מכווצת",
@@ -1245,6 +1248,12 @@
"cmd.panel.reset_pair": "איפוס מפריד חלוניות",
"cmd.panel.resize": "שינוי גודל מקטע חלונית",
"cmd.panel.resize_pair": "הזזת מפריד חלוניות",
"cmd.panel.sash.down": "הזזת מפריד הלוח למטה",
"cmd.panel.sash.left": "הזזת מפריד הלוח שמאלה",
"cmd.panel.sash.reset": "איפוס מפריד הלוח שבמיקוד",
"cmd.panel.sash.right": "הזזת מפריד הלוח ימינה",
"cmd.panel.sash.up": "הזזת מפריד הלוח למעלה",
"cmd.panel.toggle": "שינוי מצב הכיווץ של הלוח שבמיקוד",
"cmd.parameters_add": "הוספת פרמטר משתמש",
"cmd.parameters_change": "שינוי פרמטרים",
"cmd.parameters_comment": "הערה לפרמטר",
@@ -2842,6 +2851,8 @@
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
"prefs.selecting": "בחירה",
"prefs.shared_panel_layout": "שימוש בפריסת הלוחות המשותפת",
"prefs.shared_panel_layout_help": "הצגת הדפדפן, ההיסטוריה והעוזר בלוחות ניתנים להזזה. השינוי ייכנס לתוקף לאחר החלת ההעדפות.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift מוסיף בלבד; Ctrl / Cmd מחליף מצב",
"prefs.shift_add_note": "כשפועל: Shift מוסיף, Ctrl / Cmd מחליף מצב. כשכבוי (ברירת מחדל): שניהם מחליפים מצב.",
@@ -5532,6 +5543,7 @@
"ui.heading_plastic": "פלסטיק",
"ui.heading_position": "מיקום",
"ui.heading_prepare": "הכנה",
"ui.heading_press_pull": "דחיפה/משיכה",
"ui.heading_rectangular_pattern": "מערך מלבני",
"ui.heading_revolve": "סיבוב",
"ui.heading_right": "ימין",
@@ -5875,6 +5887,8 @@
"ui.profiles": "פרופילים",
"ui.project": "הטלה",
"ui.project_face_edges_sketch": "הטלת מקצועות הפאה לסקיצות חדשות",
"ui.project_references_construction": "הטלת הפניות כגאומטריית עזר",
"ui.project_references_construction_note": "הטלה, חיתוך, הכללת גאומטריה תלת־ממדית ומקצועות הפאה של סקיצה חדשה על פאה יוצרים גאומטריית עזר: הפניות לשרטוט שאינן נכללות בפרופילים. הן נשארות מקושרות למודל.",
"ui.properties": "מאפיינים",
"ui.properties.copy_matrix": "העתקת המטריצה",
"ui.properties.first_moments": "מומנטים ראשונים",
+14
View File
@@ -1236,8 +1236,11 @@
"cmd.panel.dismiss": "パネルの一時表示を閉じる",
"cmd.panel.float": "パネルをフロート表示",
"cmd.panel.focus": "パネルにフォーカス",
"cmd.panel.focus.next": "次のパネルにフォーカス",
"cmd.panel.focus.previous": "前のパネルにフォーカス",
"cmd.panel.list": "パネルを一覧表示",
"cmd.panel.move": "パネルを移動",
"cmd.panel.move.next_zone": "パネルを次の領域に移動",
"cmd.panel.native_close": "別ウィンドウのパネルを再ドッキング",
"cmd.panel.open": "パネルを開く",
"cmd.panel.peek": "折りたたんだパネルを一時表示",
@@ -1245,6 +1248,12 @@
"cmd.panel.reset_pair": "パネルの区切りをリセット",
"cmd.panel.resize": "パネルのセクションのサイズを変更",
"cmd.panel.resize_pair": "パネルの区切りを移動",
"cmd.panel.sash.down": "パネルの区切りを下に移動",
"cmd.panel.sash.left": "パネルの区切りを左に移動",
"cmd.panel.sash.reset": "フォーカス中のパネルの区切りをリセット",
"cmd.panel.sash.right": "パネルの区切りを右に移動",
"cmd.panel.sash.up": "パネルの区切りを上に移動",
"cmd.panel.toggle": "フォーカス中のパネルの折りたたみを切り替え",
"cmd.parameters_add": "ユーザー パラメータを追加",
"cmd.parameters_change": "パラメータを変更",
"cmd.parameters_comment": "パラメータにコメントを付与",
@@ -2842,6 +2851,8 @@
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
"prefs.selecting": "選択",
"prefs.shared_panel_layout": "共通のパネルレイアウトを使用",
"prefs.shared_panel_layout_help": "既存のブラウザー、履歴、アシスタントを移動可能なパネルに表示します。設定を適用すると変更が有効になります。",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
@@ -5532,6 +5543,7 @@
"ui.heading_plastic": "プラスチック",
"ui.heading_position": "配置",
"ui.heading_prepare": "準備",
"ui.heading_press_pull": "プレス プル",
"ui.heading_rectangular_pattern": "矩形パターン",
"ui.heading_revolve": "回転",
"ui.heading_right": "右側面",
@@ -5875,6 +5887,8 @@
"ui.profiles": "プロファイル",
"ui.project": "投影",
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
"ui.project_references_construction": "参照をコンストラクションジオメトリとして投影",
"ui.project_references_construction_note": "投影、交差、3D ジオメトリを含める、および面上の新規スケッチの面のエッジは、コンストラクションジオメトリになります。描画の参照用で、プロファイルには含まれません。モデルとのリンクは保たれます。",
"ui.properties": "プロパティ",
"ui.properties.copy_matrix": "行列をコピー",
"ui.properties.first_moments": "一次モーメント",
+14
View File
@@ -992,8 +992,11 @@
"cmd.panel.dismiss": "패널 임시 보기 닫기",
"cmd.panel.float": "패널 띄우기",
"cmd.panel.focus": "패널에 초점 맞추기",
"cmd.panel.focus.next": "다음 패널에 초점 맞추기",
"cmd.panel.focus.previous": "이전 패널에 초점 맞추기",
"cmd.panel.list": "패널 목록",
"cmd.panel.move": "패널 이동",
"cmd.panel.move.next_zone": "패널을 다음 영역으로 이동",
"cmd.panel.native_close": "별도 창의 패널 구역 다시 도킹",
"cmd.panel.open": "패널 열기",
"cmd.panel.peek": "접힌 패널 임시 보기",
@@ -1001,6 +1004,12 @@
"cmd.panel.reset_pair": "패널 구분선 초기화",
"cmd.panel.resize": "패널 구역 크기 조정",
"cmd.panel.resize_pair": "패널 구분선 이동",
"cmd.panel.sash.down": "패널 구분선을 아래로 이동",
"cmd.panel.sash.left": "패널 구분선을 왼쪽으로 이동",
"cmd.panel.sash.reset": "초점이 있는 패널 구분선 초기화",
"cmd.panel.sash.right": "패널 구분선을 오른쪽으로 이동",
"cmd.panel.sash.up": "패널 구분선을 위로 이동",
"cmd.panel.toggle": "초점이 있는 패널 접기 전환",
"cmd.parameters_add": "사용자 매개 변수 추가",
"cmd.parameters_change": "매개 변수 변경",
"cmd.parameters_comment": "매개 변수에 설명 추가",
@@ -2137,6 +2146,8 @@
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
"prefs.selecting": "선택",
"prefs.shared_panel_layout": "공유 패널 레이아웃 사용",
"prefs.shared_panel_layout_help": "기존 브라우저, 기록 및 도우미를 이동 가능한 패널로 표시합니다. 환경설정을 적용하면 변경 사항이 반영됩니다.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift는 추가만, Ctrl / Cmd는 토글",
"prefs.shift_add_note": "켬: Shift는 추가, Ctrl / Cmd는 토글. 끔(기본값): 둘 다 토글.",
@@ -4721,6 +4732,7 @@
"ui.heading_plastic": "플라스틱",
"ui.heading_position": "배치",
"ui.heading_prepare": "준비",
"ui.heading_press_pull": "밀기/당기기",
"ui.heading_rectangular_pattern": "직사각형 패턴",
"ui.heading_revolve": "회전",
"ui.heading_right": "우측면",
@@ -5042,6 +5054,8 @@
"ui.profiles": "프로파일",
"ui.project": "투영",
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
"ui.project_references_construction": "참조를 구성선으로 투영",
"ui.project_references_construction_note": "투영, 교차, 3D 형상 포함과 면 위 새 스케치의 면 모서리가 구성선으로 만들어집니다. 프로파일에 포함되지 않는 그리기용 참조이며, 모델과의 연결은 유지됩니다.",
"ui.properties": "속성",
"ui.properties.copy_matrix": "행렬 복사",
"ui.properties.first_moments": "1차 모멘트",
@@ -1,101 +0,0 @@
{
"ar": {
"material.editor.remove": "إزالة"
},
"bg": {
"material.editor.remove": "Remove"
},
"bs": {
"material.editor.remove": "Ukloni"
},
"ca": {
"material.editor.remove": "Eliminar"
},
"cs": {
"material.editor.remove": "Odebrat"
},
"da": {
"material.editor.remove": "Fjern"
},
"de": {
"material.editor.remove": "Entfernen"
},
"en": {
"material.editor.remove": "Remove"
},
"es": {
"material.editor.remove": "Eliminar"
},
"fi": {
"material.editor.remove": "Poista"
},
"fr": {
"material.editor.remove": "Retirer"
},
"gl": {
"material.editor.remove": "Quitar"
},
"he": {
"material.editor.remove": "הסרה"
},
"hr": {
"material.editor.remove": "Ukloni"
},
"hu": {
"material.editor.remove": "Eltávolítás"
},
"id": {
"material.editor.remove": "Hapus"
},
"it": {
"material.editor.remove": "Rimuovi"
},
"ja": {
"material.editor.remove": "削除"
},
"ka": {
"material.editor.remove": "წაშლა"
},
"ms": {
"material.editor.remove": "Buang"
},
"nb": {
"material.editor.remove": "Fjern"
},
"nl": {
"material.editor.remove": "Verwijderen"
},
"pl": {
"material.editor.remove": "Usuń"
},
"pt-BR": {
"material.editor.remove": "Remover"
},
"ro": {
"material.editor.remove": "Elimină"
},
"ru": {
"material.editor.remove": "Убрать"
},
"sr": {
"material.editor.remove": "Уклони"
},
"sv": {
"material.editor.remove": "Ta bort"
},
"tr": {
"material.editor.remove": "Kaldır"
},
"uk": {
"material.editor.remove": "Вилучити"
},
"vi": {
"material.editor.remove": "Xóa"
},
"zh-TW": {
"material.editor.remove": "刪除"
},
"zh": {
"material.editor.remove": "删除"
}
}
+14
View File
@@ -1236,8 +1236,11 @@
"cmd.panel.dismiss": "Đóng chế độ xem tạm của bảng",
"cmd.panel.float": "Thả nổi bảng",
"cmd.panel.focus": "Đặt tiêu điểm vào bảng",
"cmd.panel.focus.next": "Đặt tiêu điểm vào bảng tiếp theo",
"cmd.panel.focus.previous": "Đặt tiêu điểm vào bảng trước",
"cmd.panel.list": "Liệt kê các bảng",
"cmd.panel.move": "Di chuyển bảng",
"cmd.panel.move.next_zone": "Di chuyển bảng đến vùng tiếp theo",
"cmd.panel.native_close": "Gắn lại vùng bảng từ cửa sổ riêng",
"cmd.panel.open": "Mở bảng",
"cmd.panel.peek": "Xem tạm bảng đã thu gọn",
@@ -1245,6 +1248,12 @@
"cmd.panel.reset_pair": "Đặt lại thanh chia bảng",
"cmd.panel.resize": "Đổi kích thước vùng bảng",
"cmd.panel.resize_pair": "Di chuyển thanh chia bảng",
"cmd.panel.sash.down": "Di chuyển thanh chia bảng xuống",
"cmd.panel.sash.left": "Di chuyển thanh chia bảng sang trái",
"cmd.panel.sash.reset": "Đặt lại thanh chia bảng đang có tiêu điểm",
"cmd.panel.sash.right": "Di chuyển thanh chia bảng sang phải",
"cmd.panel.sash.up": "Di chuyển thanh chia bảng lên",
"cmd.panel.toggle": "Bật/tắt thu gọn bảng đang có tiêu điểm",
"cmd.parameters_add": "Thêm tham số người dùng",
"cmd.parameters_change": "Thay đổi tham số",
"cmd.parameters_comment": "Thêm chú thích tham số",
@@ -2842,6 +2851,8 @@
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
"prefs.selecting": "Đang chọn",
"prefs.shared_panel_layout": "Dùng bố cục bảng dùng chung",
"prefs.shared_panel_layout_help": "Hiển thị Trình duyệt, Lịch sử và Trợ lý hiện có trong các bảng có thể di chuyển. Thay đổi có hiệu lực khi áp dụng tùy chọn.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift chỉ thêm; Ctrl / Cmd đảo trạng thái chọn",
"prefs.shift_add_note": "Bật: Shift để thêm vào vùng chọn, Ctrl / Cmd để đảo chọn. Tắt (mặc định): cả hai đều đảo trạng thái chọn.",
@@ -5532,6 +5543,7 @@
"ui.heading_plastic": "CHI TIẾT NHỰA",
"ui.heading_position": "VỊ TRÍ",
"ui.heading_prepare": "CHUẨN BỊ",
"ui.heading_press_pull": "KÉO/ĐẨY",
"ui.heading_rectangular_pattern": "SAO CHÉP HÌNH CHỮ NHẬT",
"ui.heading_revolve": "XOAY TRÒN TẠO KHỐI",
"ui.heading_right": "HÌNH CHIẾU CẠNH",
@@ -5875,6 +5887,8 @@
"ui.profiles": "Các biên dạng",
"ui.project": "Chiếu hình học",
"ui.project_face_edges_sketch": "Chiếu các cạnh bề mặt vào phác thảo mới",
"ui.project_references_construction": "Chiếu tham chiếu thành đường dựng hình",
"ui.project_references_construction_note": "Chiếu, Giao, Đưa vào hình học 3D và các cạnh bề mặt của phác thảo mới trên một bề mặt tạo ra đường dựng hình: tham chiếu để vẽ theo, không thuộc biên dạng. Chúng vẫn liên kết với mô hình.",
"ui.properties": "Thuộc tính cơ lý",
"ui.properties.copy_matrix": "Sao chép ma trận",
"ui.properties.first_moments": "Mômen bậc nhất",
+14
View File
@@ -1236,8 +1236,11 @@
"cmd.panel.dismiss": "关闭面板临时预览",
"cmd.panel.float": "浮动面板",
"cmd.panel.focus": "聚焦面板",
"cmd.panel.focus.next": "聚焦下一个面板",
"cmd.panel.focus.previous": "聚焦上一个面板",
"cmd.panel.list": "列出面板",
"cmd.panel.move": "移动面板",
"cmd.panel.move.next_zone": "将面板移至下一个区域",
"cmd.panel.native_close": "重新停靠独立窗口中的面板分区",
"cmd.panel.open": "打开面板",
"cmd.panel.peek": "临时预览折叠面板",
@@ -1245,6 +1248,12 @@
"cmd.panel.reset_pair": "重置面板分隔条",
"cmd.panel.resize": "调整面板分区大小",
"cmd.panel.resize_pair": "移动面板分隔条",
"cmd.panel.sash.down": "向下移动面板分隔条",
"cmd.panel.sash.left": "向左移动面板分隔条",
"cmd.panel.sash.reset": "重置当前焦点面板的分隔条",
"cmd.panel.sash.right": "向右移动面板分隔条",
"cmd.panel.sash.up": "向上移动面板分隔条",
"cmd.panel.toggle": "切换当前焦点面板的折叠状态",
"cmd.parameters_add": "添加用户参数",
"cmd.parameters_change": "更改参数",
"cmd.parameters_comment": "注释参数",
@@ -2842,6 +2851,8 @@
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
"prefs.selecting": "选择",
"prefs.shared_panel_layout": "使用共享面板布局",
"prefs.shared_panel_layout_help": "在可移动面板中显示现有的浏览器、历史记录和助手。应用首选项后更改生效。",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
@@ -5532,6 +5543,7 @@
"ui.heading_plastic": "塑料",
"ui.heading_position": "位置",
"ui.heading_prepare": "准备",
"ui.heading_press_pull": "按压拖动",
"ui.heading_rectangular_pattern": "矩形阵列",
"ui.heading_revolve": "旋转",
"ui.heading_right": "右视",
@@ -5875,6 +5887,8 @@
"ui.profiles": "轮廓",
"ui.project": "投影",
"ui.project_face_edges_sketch": "将面的边投影到新草图",
"ui.project_references_construction": "将参考投影为构造几何",
"ui.project_references_construction_note": "投影、相交、包含三维几何以及在面上新建草图时投影的面边,都会生成构造几何:用于绘图参考,不计入轮廓。它们仍与模型保持关联。",
"ui.properties": "属性",
"ui.properties.copy_matrix": "复制矩阵",
"ui.properties.first_moments": "一阶矩",
+9 -3
View File
@@ -716,14 +716,20 @@ fn lit_color(i: TOut, visibility: f32, base: vec4<f32>) -> vec4<f32> {
if (dot(n, v) < 0.0) { n = -n; }
if u.product_lights_meta.x>0.0 {
var radiance=vec3<f32>(0.0);
let albedo=srgb_decode(base.rgb);
for (var k=0u;k<min(u32(u.product_lights_meta.y),21u);k+=1u) {
if u.product_lights[k*4u].w == 4.0 {radiance+=to_linear(base.rgb)*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
if u.product_lights[k*4u].w == 4.0 {radiance+=albedo*u.product_lights[k*4u+1u].xyz/3.141592653589793;continue;}
let sample=product_light_sample(k,i.wp);
radiance+=to_linear(base.rgb)*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
radiance+=albedo*sample[1]*(max(dot(n,sample[0]),0.0)/3.141592653589793);
}
let mapped=radiance/(vec3<f32>(1.0)+radiance);
// A linear attachment takes linear light as it is; any other is encoded once, as the CPU
// image is (decoding the encoded value again through `out_color` would flatten the dark end).
if (u.back.w > 0.5) {
return vec4<f32>(mapped,base.a);
}
let encoded=select(mapped*12.92,1.055*pow(mapped,vec3<f32>(1.0/2.4))-vec3<f32>(0.055),mapped>vec3<f32>(0.0031308));
return out_color(vec4<f32>(encoded,base.a));
return vec4<f32>(encoded,base.a);
}
let key = normalize(v * 0.8 + vec3<f32>(-0.3, 0.2, 0.6));
let diff = max(dot(n, key), 0.0);
@@ -1,21 +1,6 @@
//! Original light and dark palette values.
use crate::{IconColors, Tokens};
//! Original light and dark palette values. Compiled by the UI theme boundary.
use crate::theme::{AnalysisColors, IconColors, Tokens};
use egui::Color32;
use solidcraft_color::AnalysisColors;
/// The primary (`active-3`) strokes, light then dark, that the tokens share with SolidCraft's icon artwork
/// (`assets/icons/ui/design/palette.toml`, including its colour vision variants). ui-egui's icon tests hold them equal.
pub mod icon_strokes {
pub const SKETCH_PRIMARY: [[u8; 3]; 2] = [[5, 127, 69], [18, 141, 69]];
pub const CONSTRAINT_PRIMARY: [[u8; 3]; 2] = [[202, 71, 61], [193, 99, 88]];
pub const CONSTRUCTION_PRIMARY: [[u8; 3]; 2] = [[156, 86, 0], [167, 93, 0]];
pub const SKETCH_RED_GREEN: [[u8; 3]; 2] = [[29, 125, 82], [29, 127, 137]];
pub const CONSTRUCTION_RED_GREEN: [[u8; 3]; 2] = [[185, 115, 0], [167, 93, 0]];
pub const CONSTRAINT_RED_GREEN: [[u8; 3]; 2] = [[175, 64, 56], [193, 99, 88]];
pub const CONSTRUCTION_BLUE_YELLOW: [[u8; 3]; 2] = [[185, 115, 0], [195, 125, 0]];
pub const CONSTRAINT_BLUE_YELLOW: [[u8; 3]; 2] = [[175, 64, 56], [133, 85, 74]];
}
use icon_strokes as strokes;
const fn icon_primary(rgb: [u8; 3]) -> Color32 {
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
@@ -32,7 +17,8 @@ impl Tokens {
nav_tip_key_border: Color32::from_rgba_unmultiplied_const(128, 140, 160, 153),
nav_field: Color32::from_rgb(255, 255, 255),
borders: crate::Borders::LEGACY,
borders: crate::theme::Borders::LEGACY,
icon_palette: None,
file_dim: Color32::from_rgb(58, 70, 86),
file_recovery_well: Color32::from_rgb(247, 226, 184),
file_action_height: 32.0,
@@ -133,7 +119,7 @@ impl Tokens {
text_dim: Color32::from_rgb(110, 116, 128),
accent: Color32::from_rgb(38, 120, 218),
accent_soft: Color32::from_rgb(214, 230, 250),
sketch_accent: icon_primary(strokes::SKETCH_PRIMARY[0]),
sketch_accent: icon_primary(crate::icon_svg::SKETCH_PRIMARY[0]),
// Icon palette active-3 ramps; these accents have no compiled constants yet.
surface_accent: Color32::from_rgb(164, 80, 0),
mesh_accent: Color32::from_rgb(130, 103, 0),
@@ -186,7 +172,7 @@ impl Tokens {
axis_y: Color32::from_rgb(60, 170, 60),
axis_z: Color32::from_rgb(60, 100, 230),
construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70),
construction_geom: icon_primary(strokes::CONSTRUCTION_PRIMARY[0]),
construction_geom: icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[0]),
body: Color32::from_rgb(176, 186, 198),
body_edge: Color32::from_rgb(40, 44, 52),
manipulator: Color32::from_rgb(40, 110, 220),
@@ -229,7 +215,7 @@ impl Tokens {
profile_fill: Color32::from_rgba_unmultiplied_const(255, 196, 120, 70),
glyph_bg: Color32::WHITE,
glyph_edge: Color32::from_rgb(190, 110, 100),
glyph_text: icon_primary(strokes::CONSTRAINT_PRIMARY[0]),
glyph_text: icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[0]),
overlay: Color32::from_rgba_unmultiplied_const(255, 255, 255, 235),
rubber_band: Color32::from_rgb(30, 120, 230),
app_bar_text: Color32::from_rgb(230, 234, 242),
@@ -353,7 +339,7 @@ impl Tokens {
t.text_dim = Color32::from_rgb(148, 155, 168);
t.accent = Color32::from_rgb(58, 136, 238);
t.accent_soft = Color32::from_rgb(42, 70, 110);
t.sketch_accent = icon_primary(strokes::SKETCH_PRIMARY[1]);
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_PRIMARY[1]);
t.surface_accent = Color32::from_rgb(149, 104, 45);
t.mesh_accent = Color32::from_rgb(133, 113, 0);
t.form_accent = Color32::from_rgb(123, 99, 166);
@@ -413,7 +399,7 @@ impl Tokens {
t.pref_ok = Color32::from_rgb(127, 209, 174);
t.pref_ok_fill = Color32::from_rgb(31, 58, 49);
t.pref_disabled = Color32::from_rgb(138, 148, 163);
t.construction_geom = icon_primary(strokes::CONSTRUCTION_PRIMARY[1]);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_PRIMARY[1]);
t.error = Color32::from_rgb(240, 100, 96);
t.sketch_conflict = t.error;
t.warning = Color32::from_rgb(236, 170, 70);
@@ -444,7 +430,7 @@ impl Tokens {
t.chip = Color32::from_rgb(59, 101, 168);
t.glyph_bg = Color32::from_rgb(44, 50, 60);
t.glyph_edge = Color32::from_rgb(200, 130, 120);
t.glyph_text = icon_primary(strokes::CONSTRAINT_PRIMARY[1]);
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_PRIMARY[1]);
t.overlay = Color32::from_rgba_unmultiplied_const(34, 38, 46, 240);
t.rubber_band = Color32::from_rgb(92, 160, 255);
t.timeline_item = Color32::from_rgb(52, 58, 70);
@@ -487,8 +473,8 @@ impl Tokens {
if !dark {
t.form_success = Color32::from_rgb(39, 88, 78);
}
t.construction_geom = icon_primary(strokes::CONSTRUCTION_RED_GREEN[dark as usize]);
t.glyph_text = icon_primary(strokes::CONSTRAINT_RED_GREEN[dark as usize]);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_RED_GREEN[dark as usize]);
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_RED_GREEN[dark as usize]);
// A deeper red keeps conflicting sketch geometry (#7244) apart from the orange
// construction and centerline lines and the crimson lost links for a red-green loss.
t.sketch_conflict = Color32::from_rgb(195, 35, 30);
@@ -498,14 +484,14 @@ impl Tokens {
if dark {
t.bend_up = Color32::from_rgb(110, 165, 255);
t.bend_down = Color32::from_rgb(240, 180, 70);
t.sketch_accent = icon_primary(strokes::SKETCH_RED_GREEN[1]);
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[1]);
} else {
t.sketch_projected = Color32::from_rgb(205, 120, 215);
t.sketch_locked = Color32::from_rgb(60, 150, 120);
t.timeline_item_error = Color32::from_rgb(232, 196, 170);
t.bend_up = Color32::from_rgb(30, 100, 210);
t.bend_down = Color32::from_rgb(200, 120, 0);
t.sketch_accent = icon_primary(strokes::SKETCH_RED_GREEN[0]);
t.sketch_accent = icon_primary(crate::icon_svg::SKETCH_RED_GREEN[0]);
}
t
}
@@ -520,8 +506,8 @@ impl Tokens {
// Green and blue status lights merge for tritan vision; use rose for working.
t.agent_busy = Color32::from_rgb(232, 150, 190);
t.preview_sketch = Color32::from_rgb(125, 180, 105);
t.glyph_text = icon_primary(strokes::CONSTRAINT_BLUE_YELLOW[dark as usize]);
t.construction_geom = icon_primary(strokes::CONSTRUCTION_BLUE_YELLOW[dark as usize]);
t.glyph_text = icon_primary(crate::icon_svg::CONSTRAINT_BLUE_YELLOW[dark as usize]);
t.construction_geom = icon_primary(crate::icon_svg::CONSTRUCTION_BLUE_YELLOW[dark as usize]);
// Conflicting sketch geometry (#7244) apart from the lost links and the construction and
// centerline lines for a blue-yellow loss: darker on the light view, lighter on the dark.
t.sketch_conflict = if dark { Color32::from_rgb(255, 170, 160) } else { Color32::from_rgb(165, 15, 30) };
-9
View File
@@ -1,9 +0,0 @@
[package]
name = "solidcraft-color"
description = "Colour rules and data any crate may share: the sRGB transfer function and the surface analysis palette"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
-20
View File
@@ -1,20 +0,0 @@
# solidcraft-color
Colour rules and data that any crate may share. It has no dependencies and holds no app types,
so the renderer, the document, the importers and the UI theme all use the same rule and the same
colours instead of keeping copies.
The sRGB transfer function (IEC 61966-2-1) lives here once: every encode and decode in Rust goes
through `srgb`, so a change to the rule is one change. Shaders can't call Rust and keep their own
copies.
## API
| Item | What it is |
|---|---|
| `srgb::encode(linear)` | Linear light (0..1) to an sRGB value (0..1) |
| `srgb::decode(encoded)` | An sRGB value (0..1) to linear light |
| `srgb::encode8(linear)` | Linear light to the nearest 8-bit sRGB code (out of range clamps, NaN reads as 0) |
| `srgb::decode8(code)` | An 8-bit sRGB code to linear light |
| `AnalysisColors` | The surface analysis palette (draft, accessibility, curvature, hatch), straight sRGB 0..1 as the shader takes it; the theme fills it and the renderer reads it |
| `AnalysisColors::DEFAULT` | The light theme's colours with no colour vision mode, as headless pictures use |
-32
View File
@@ -1,32 +0,0 @@
//! The colours a surface analysis is shaded in.
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
/// reads with hue lost too (#975).
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
impl AnalysisColors {
/// The light theme's colours (no colour vision mode): what headless pictures use.
pub const DEFAULT: AnalysisColors = AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
hatch: false,
};
}
impl Default for AnalysisColors {
fn default() -> Self {
AnalysisColors::DEFAULT
}
}
-8
View File
@@ -1,8 +0,0 @@
//! Colour rules and data shared by every crate: the sRGB transfer function ([`srgb`]) and the surface analysis palette
//! ([`AnalysisColors`]). No app types and no dependencies, so the renderer, the document and the theme all read the same
//! rule and the same colours.
mod analysis;
pub mod srgb;
pub use analysis::AnalysisColors;
-1
View File
@@ -10,7 +10,6 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
+1 -1
View File
@@ -154,7 +154,7 @@ impl VisualPreset {
match self.recipe {
Recipe::Opaque(r) => r.color,
// A UI swatch of the transmission values, not rendered glass or absorption.
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_color::srgb::encode8),
Recipe::Glass(r) => r.transmission_linear_rgb.map(solidcraft_geom::srgb::encode8),
}
}
+16
View File
@@ -7021,6 +7021,22 @@ fn replay(
instances.push(start..copies.len());
}
}
// A cut or intersect copy with no named targets, made where the pattern's own component
// has no body for it, works on the bodies of its source's component, as the source did
// (#12996). A pattern whose component has bodies cuts them, as before. The same scope
// counts misses, takes sheet cuts and applies the copies.
let scoped;
let f = if matches!(op, Operation::Cut | Operation::Intersect)
&& targets.is_empty()
&& f.participants.is_empty()
&& src.component != f.component
&& !st.bodies.iter().any(|b| b.component == f.component || b.feature == f.id)
{
scoped = Feature { component: src.component, ..f.clone() };
&scoped
} else {
f
};
if op == Operation::Cut {
let (m, t) = count_copy_misses(st, f, &targets, &copies, &instances, &axes);
missed += m;
+5 -2
View File
@@ -571,9 +571,12 @@ pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match>
if descends(current, name) {
return Some(Match::Piece);
}
// The named piece, told apart by the faces around it: they still bound this piece.
// The named piece, told apart by the faces around it: they still bound this piece. Or the
// face it was a piece of, whole again since, which they still bound: the piece is all of it
// (#11731). Only a piece told apart by faces: a numbered one (`#2`, nothing told it apart)
// says nothing about which part of the whole face it was.
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
&& crate::naming::parent_piece(current) == Some(stem)
&& (crate::naming::parent_piece(current) == Some(stem) || (current == stem && !around.is_empty() && name.ends_with(']')))
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
{
return Some(Match::Piece);
+18
View File
@@ -171,3 +171,21 @@ fn a_linked_designs_face_names_keep_their_pieces_and_nothing_else() {
let _ = role(n);
}
}
/// A face that was split when a name was stored and is whole again (#11731): the stored piece
/// is all of it, when the face still touches the faces that told the piece apart.
#[test]
fn a_piece_name_finds_its_face_once_it_is_whole_again() {
let faces = named(&["top", "left", "right", "start"]);
let names = qualify(faces, &[(0, 1), (0, 3)]);
assert_eq!(names.faces[0], "top");
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
assert_eq!(matches(&names, 0, "top#[left,start]"), Some(Match::Piece));
// It no longer touches a face the qualifier names: some other piece, gone since.
assert_eq!(matches(&names, 0, "top#[right]"), Some(Match::Stem));
assert_eq!(matches(&names, 0, "top#[left]@F9.1"), None, "a copy's piece");
// A numbered piece (nothing told it apart, or a tie) says nothing about which part it was.
assert_eq!(matches(&names, 0, "top#1"), Some(Match::Stem));
assert_eq!(matches(&names, 0, "top#[left]#2"), Some(Match::Stem));
assert_eq!(matches(&names, 0, "top#[]"), Some(Match::Stem));
}
+54
View File
@@ -243,3 +243,57 @@ fn numbered_projection_refuses_a_pick_inside_a_hole_in_the_named_face() {
assert_eq!(pick(5.).map(|(index, _)| index), Some(top));
assert!(pick(0.).is_none(), "the pick is on the plane but inside the hole");
}
/// An edge picked when its side face was split in two keeps the piece in its name
/// (`F2:side:l1#[F2:start]`); once the side is one face again the edge is still found (#11731).
#[test]
fn an_edge_named_by_a_piece_of_a_face_that_is_whole_again_is_found() {
let doc = Document {
features: serde_json::from_value(serde_json::json!([
{
"id": 1, "name": "Sketch1", "type": "sketch", "plane": {"name": "XY", "type": "origin"},
"sketch": {
"points": [
{"fixed": true, "id": "origin", "pos": [0., 0.]},
{"fixed": false, "id": "p1", "pos": [0., 0.]},
{"fixed": false, "id": "p2", "pos": [10., 0.]},
{"fixed": false, "id": "p3", "pos": [10., 16.1]},
{"fixed": false, "id": "p4", "pos": [0., 16.1]}
],
"curves": [
{"a": 1, "b": 2, "construction": false, "id": "l1", "type": "line"},
{"a": 2, "b": 3, "construction": false, "id": "l2", "type": "line"},
{"a": 3, "b": 4, "construction": false, "id": "l3", "type": "line"},
{"a": 4, "b": 1, "construction": false, "id": "l4", "type": "line"}
],
"constraints": [],
"counters": {"k": 0, "l": 4, "p": 4}
}
},
{
"id": 2, "name": "Extrude1", "type": "extrude", "body_names": ["Body1"],
"extent": {"convention": "total_distance", "direction": "positive", "distance": "5"},
"operation": "new_body", "profiles": {"loops": [["l1", "l2", "l3", "l4"]], "type": "curves"}, "sketch": 1
}
]))
.unwrap(),
..Document::default()
};
let doc = crate::format::read(&doc.to_json()).unwrap();
let mut model = crate::Model::new();
model.evaluate(&doc);
let state = model.state();
let body = state.body("Body1").unwrap();
// The side along x = 0 meets the start cap in the edge under the pick.
let near = Vec3::new(0., 8.05, 0.);
let names = crate::naming::edge_names(body);
let whole = names.iter().find(|n| n.contains("F2:start") && n.contains("F2:side:l4")).expect("the side's edge on the start cap");
let piece = whole.replace("F2:side:l4", "F2:side:l4#[F2:start]");
let hit = crate::name_lookup::locate_edge(body, &piece, near).unwrap_or_else(|e| panic!("{piece}: {e}"));
assert!(hit.on_point, "the edge under the saved pick");
assert_eq!(hit.point, near);
assert_eq!(names.get(hit.edge), Some(whole));
// A piece told apart by a face the side never touches (the opposite side) is still refused.
let gone = whole.replace("F2:side:l4", "F2:side:l4#[F2:side:l2]");
assert!(crate::name_lookup::locate_edge(body, &gone, near).is_err(), "{gone}");
}
-1
View File
@@ -36,7 +36,6 @@ required-features = ["solver-profiling"]
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
@@ -1552,3 +1552,57 @@ fn join_that_meets_no_body_keeps_the_piece_and_says_so() {
let combine = s.execute("solid.combine", &json!({"target": "A", "tools": ["B"], "operation": "join"})).unwrap();
assert!(combine["warning"].is_null(), "{combine}");
}
/// #12996: a circular pattern of an untargeted cut made in a component, run with another
/// component active, cuts the bodies of the cut's own component, as the cut did.
#[test]
fn pattern_of_an_untargeted_cut_in_a_component_works_with_root_active() {
let mut s = Session::default();
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, &params).unwrap();
run(&mut s, "component.create", json!({"name": "Main Body"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
let one_hole = volume(&s, "Body1");
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Other"}));
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
let hole = 10.0 * 16.0 * std::f64::consts::PI;
let got = volume(&s, "Body1");
assert!((one_hole - got - 9.0 * hole).abs() < 0.02 * hole, "{one_hole} -> {got}");
}
/// #12996: when the component active for the pattern has a body of its own, the copies of an
/// untargeted cut keep cutting it, as they did before: saved designs rebuild the same.
#[test]
fn pattern_of_an_untargeted_cut_still_cuts_its_own_components_body() {
let mut s = Session::default();
let run = |s: &mut Session, command: &str, params: Value| s.execute(command, &params).unwrap();
run(&mut s, "component.create", json!({"name": "Main Body"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Body1"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 4}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "direction": "negative", "operation": "cut", "name": "Extrude62"}));
let one_hole = volume(&s, "Body1");
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Other"}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-50, -50], "p1": [50, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "20 mm", "operation": "new", "body_name": "Plate2"}));
let plate = volume(&s, "Plate2");
run(&mut s, "solid.pattern.circular", json!({"features": ["Extrude62"], "axis": "Z", "count": 10}));
let hole = 10.0 * 16.0 * std::f64::consts::PI;
let (body1, plate2) = (volume(&s, "Body1"), volume(&s, "Plate2"));
assert!((one_hole - body1).abs() < 0.02 * hole, "Body1 untouched: {one_hole} -> {body1}");
assert!((plate - plate2 - 9.0 * hole).abs() < 0.02 * hole, "Plate2 cut: {plate} -> {plate2}");
}
+99 -37
View File
@@ -13,11 +13,13 @@ use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::schema::component::{
ActivateParams, ActivateResult, ChildEntry, CompactPlacement, ComponentNode, CreateParams, CreateResult, DeleteParams, DeleteResult,
EditInPlaceParams, EditInPlaceResult, FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, ListParams, ListResult,
MoveFeaturesParams, MoveFeaturesResult, MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams,
OccurrenceAlignResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams,
ReparentItemsResult, UnlockParams, UnlockResult,
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, ChildEntry, CompactPlacement, ComponentNode, CopyParams,
CopyResult, CreateParams, CreateResult, DeleteParams, DeleteResult, DragParams, DragResult, EditInPlaceParams, EditInPlaceResult,
FinishEditParams, FinishEditResult, FromBodiesParams, FromBodiesResult, GroundParams, GroundResult, ListParams, ListResult, MoveBodiesParams,
MoveBodiesResult, MoveFeaturesParams, MoveFeaturesResult, MoveParams, MoveResult, MoveSketchesParams, MoveSketchesResult,
MoveSketchesToPartsParams, MoveSketchesToPartsResult, NamesOrCount, OccurrenceAlignParams, OccurrenceAlignResult, OccurrencePropertiesParams,
OccurrencePropertiesResult, PasteNewParams, PasteNewResult, PlacementEntry, PlacementInfo, PropertiesParams, PropertiesResult, RenameParams,
RenameResult, ReparentItemsParams, ReparentItemsResult, RevertPositionParams, RevertPositionResult, UnlockParams, UnlockResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
@@ -35,6 +37,16 @@ const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new
const MOVE_FEATURES_CONTRACT: TypedContract<MoveFeaturesParams> = TypedContract::new();
const MOVE_SKETCHES_TO_PARTS_CONTRACT: TypedContract<MoveSketchesToPartsParams> = TypedContract::new();
const REPARENT_ITEMS_CONTRACT: TypedContract<ReparentItemsParams> = TypedContract::new();
const CAPTURE_POSITION_CONTRACT: TypedContract<CapturePositionParams> = TypedContract::new();
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
const DRAG_CONTRACT: TypedContract<DragParams> = TypedContract::new();
const GROUND_CONTRACT: TypedContract<GroundParams> = TypedContract::new();
const MOVE_CONTRACT: TypedContract<MoveParams> = TypedContract::new();
const MOVE_BODIES_CONTRACT: TypedContract<MoveBodiesParams> = TypedContract::new();
const MOVE_SKETCHES_CONTRACT: TypedContract<MoveSketchesParams> = TypedContract::new();
const OCCURRENCE_PROPERTIES_CONTRACT: TypedContract<OccurrencePropertiesParams> = TypedContract::new();
const PASTE_NEW_CONTRACT: TypedContract<PasteNewParams> = TypedContract::new();
const REVERT_POSITION_CONTRACT: TypedContract<RevertPositionParams> = TypedContract::new();
#[path = "component_align.rs"]
mod align;
@@ -133,8 +145,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&PROPERTIES_CONTRACT)
.output(output_schema_for::<PropertiesResult>)
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", crate::checked!("occurrence.properties", properties::occurrence))
.icon("measure").noundo()
.contract(&OCCURRENCE_PROPERTIES_CONTRACT)
.output(output_schema_for::<OccurrencePropertiesResult>)
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
CommandSpec::new("component.rename", "Rename Component", crate::checked!("component.rename", rename))
.icon("rename")
@@ -154,7 +168,11 @@ pub static COMMANDS: &[CommandSpec] = &[
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).icon("move").params(
CommandSpec::new("component.move_bodies", "Move Bodies to Component", crate::checked!("component.move_bodies", move_bodies))
.icon("move")
.contract(&MOVE_BODIES_CONTRACT)
.output(output_schema_for::<MoveBodiesResult>)
.params(
"bodies: [names], component; component_path?: its placement to place them by (default: its only one, else the active \
one; a component placed more than once gets them in every instance); from_path?: the placement of their component they \
move from (default: its only one, else its first, where its bodies have their own names)",
@@ -173,8 +191,10 @@ pub static COMMANDS: &[CommandSpec] = &[
A component that uses what isn't in it (another body, sketch or parameter) is refused, saying what. Returns \
`bodies` and `feature`, or the pasted components' `paths`",
),
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches)
CommandSpec::new("component.move_sketches", "Move Sketches to Component", crate::checked!("component.move_sketches", move_sketches))
.icon("move")
.contract(&MOVE_SKETCHES_CONTRACT)
.output(output_schema_for::<MoveSketchesResult>)
.params("sketches: [ids or names], component; the sketches keep their own coordinates (component.move_features keeps them where they are in the world), except that a sketch on an exactly attached face stays where it is on the face"),
CommandSpec::new(
"component.move_sketches.to_parts",
@@ -210,12 +230,26 @@ pub static COMMANDS: &[CommandSpec] = &[
nothing: placements move first (taking what they hold), then features (taking the bodies they made), then the bodies \
still elsewhere (a body transfer, which leaves its features behind)",
),
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence).icon("copy")
CommandSpec::new("occurrence.copy", "Paste (Instance)", crate::checked!("occurrence.copy", copy_occurrence))
.icon("copy")
.contract(&COPY_CONTRACT)
.output(output_schema_for::<CopyResult>)
.params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]; in_place?: bool (default false: the selected transform within the parent; true: where the selected placement is in the world, translate in world axes; a parent placed more than once is placed by parent_path?, else the active placement, and every instance gets the copy). Copies the selected effective transform, including pending moves"),
CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move")
CommandSpec::new("component.paste_new", "Paste New", crate::checked!("component.paste_new", paste_new))
.icon("paste")
.contract(&PASTE_NEW_CONTRACT)
.output(output_schema_for::<PasteNewResult>)
.params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; parent?: component the copy goes in (default: the source placement's; not the component or one inside it); in_place?, parent_path?: as occurrence.copy; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"),
CommandSpec::new("occurrence.move", "Move Occurrence", crate::checked!("occurrence.move", move_occurrence))
.icon("move")
.contract(&MOVE_CONTRACT)
.output(output_schema_for::<MoveResult>)
.params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared. Replies {occurrence, transform, pending}; when joints hold it to something fixed and put it back, transform is where it is and the reply adds reverted: true and a warning naming the joint"),
CommandSpec::new("occurrence.drag", "Drag Occurrence", drag::drag).icon("move").params(
CommandSpec::new("occurrence.drag", "Drag Occurrence", crate::checked!("occurrence.drag", drag::drag))
.icon("move")
.contract(&DRAG_CONTRACT)
.output(output_schema_for::<DragResult>)
.params(
"occurrence or path: exact placement; at: [x,y,z] world point on it; point: [x,y,z] where it goes. Moves the placement by \
point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \
moves stay pending. Shared definitions move in all placements. Grounded parts and parts joints hold to something fixed are refused (joint.reach moves those)",
@@ -230,14 +264,29 @@ pub static COMMANDS: &[CommandSpec] = &[
world coordinates without changing shared component geometry; a placement inside a component placed more than once moves in \
every placement of it, and the reply's `shared` lists those paths",
),
CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("occurrence.ground", "Ground", crate::checked!("occurrence.ground", ground))
.icon("ground")
.contract(&GROUND_CONTRACT)
.output(output_schema_for::<GroundResult>)
.params("occurrence; grounded?: bool (default toggles)"),
CommandSpec::new("occurrence.reparent", "Move to Component", reparent::reparent).icon("move").params(
"occurrence; path?: exact placement; parent: component id or name (0 or \"root\" for the design); parent_path?: the parent's \
placement to place it by (default: its only one, else the active one; every instance of the parent gets it). The placement \
keeps its world position; a move that would break a feature is refused",
),
CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
CommandSpec::new("component.capture_position", "Capture Position", crate::checked!("component.capture_position", capture))
.at("SOLID", "POSITION")
.icon("capture")
.contract(&CAPTURE_POSITION_CONTRACT)
.output(output_schema_for::<CapturePositionResult>)
.params("keeps pending occurrence moves"),
CommandSpec::new("component.revert_position", "Revert Position", crate::checked!("component.revert_position", revert))
.at("SOLID", "POSITION")
.icon("revert")
.noundo()
.contract(&REVERT_POSITION_CONTRACT)
.output(output_schema_for::<RevertPositionResult>)
.params("occurrences?: [ids] (default all); drops selected pending occurrence moves"),
];
const ALIGN_CONTRACT: TypedContract<OccurrenceAlignParams> = TypedContract::new();
@@ -269,7 +318,7 @@ fn translation(t: Vec3) -> Mat {
solidcraft_doc::rigid(t, Vec3::ZERO, Vec3::Z, 0.0)
}
fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
pub(crate) fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str) -> Result<Vec3> {
let v = match p.get(key) {
None => default,
Some(value) => vec3(value).ok_or_else(|| bad(cmd, format!("{key} must contain three finite numbers")))?,
@@ -621,7 +670,7 @@ fn move_sketches(s: &mut Session, p: &Value) -> Result<Value> {
f.component = to;
}
}
Ok(json!({"sketches": ids, "component": to}))
reply(MoveSketchesResult { sketches: ids, component: to, param_warnings: Vec::new() })
}
pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
@@ -655,7 +704,7 @@ pub(super) fn copy_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "copy transform exceeds supported numeric range"));
}
let id = s.doc_mut().add_occurrence(comp, parent, m)?;
Ok(json!({"occurrence": id, "component": comp}))
reply(CopyResult { occurrence: id, component: comp, param_warnings: Vec::new() })
}
fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
@@ -785,17 +834,7 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
delta
};
let cur = if let Some(value) = p.get("base_transform") {
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "base_transform must be a 4x4 affine matrix"))?;
if !valid_position(&base)
|| base[0][3].abs() > 1e-12
|| base[1][3].abs() > 1e-12
|| base[2][3].abs() > 1e-12
|| (base[3][3] - 1.0).abs() > 1e-12
|| solidcraft_doc::mat_inverse(&base).is_none()
{
return Err(bad(cmd, "base_transform must be finite, affine and invertible"));
}
base
base_transform(value).map_err(|message| bad(cmd, message))?
} else {
s.uncaptured_moves().get(&id).copied().unwrap_or(o.transform)
};
@@ -809,24 +848,47 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
x.transform = next;
}
if !s.doc.assembly.joints.is_empty() {
return Ok(after_joints(s, p, id, next, cmd));
return reply(after_joints(s, p, id, next, cmd));
}
} else {
s.pending_moves.insert(id, next);
s.revision += 1;
}
Ok(json!({"occurrence": id, "transform": next, "pending": !bool_(p, "capture").unwrap_or(true)}))
reply(MoveResult {
occurrence: id,
transform: next,
pending: !bool_(p, "capture").unwrap_or(true),
reverted: false,
warning: None,
param_warnings: Vec::new(),
})
}
/// A move's `base_transform`: a 4×4 matrix that is finite, affine and invertible, or what is
/// wrong with it. The parameters contract reads it the same way.
pub(crate) fn base_transform(value: &Value) -> std::result::Result<Mat, &'static str> {
let base: Mat = serde_json::from_value(value.clone()).map_err(|_| "base_transform must be a 4x4 affine matrix")?;
if !valid_position(&base)
|| base[0][3].abs() > 1e-12
|| base[1][3].abs() > 1e-12
|| base[2][3].abs() > 1e-12
|| (base[3][3] - 1.0).abs() > 1e-12
|| solidcraft_doc::mat_inverse(&base).is_none()
{
return Err("base_transform must be finite, affine and invertible");
}
Ok(base)
}
/// The reply to a captured move once the joints are solved (#11723): a part its joints hold to
/// something fixed is put back by that solve, so the reply gives where it is now, says
/// `reverted`, and names the joint and what to change instead.
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> Value {
fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> MoveResult {
s.refresh();
let now = s.doc.occurrences.iter().find(|o| o.id == id).map_or(moved, |o| o.transform);
let same = now.iter().flatten().zip(moved.iter().flatten()).all(|(a, b)| (a - b).abs() <= 1e-6);
if same {
return json!({"occurrence": id, "transform": now, "pending": false});
return MoveResult { occurrence: id, transform: now, pending: false, reverted: false, warning: None, param_warnings: Vec::new() };
}
let name = s.doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone()).unwrap_or_default();
// The placement the caller named, else the first one: a part in a component placed more
@@ -855,7 +917,7 @@ fn after_joints(s: &mut Session, p: &Value, id: u64, moved: Mat, cmd: &str) -> V
"{why}The joints put {name} back where they hold it. To move it, change the joint (joint.edit for its offset or angle, \
joint.drive for a hinge or slider value), move what it is held to, or use joint.reach"
);
json!({"occurrence": id, "transform": now, "pending": false, "reverted": true, "warning": warning})
MoveResult { occurrence: id, transform: now, pending: false, reverted: true, warning: Some(warning), param_warnings: Vec::new() }
}
fn ground(s: &mut Session, p: &Value) -> Result<Value> {
@@ -868,7 +930,7 @@ fn ground(s: &mut Session, p: &Value) -> Result<Value> {
let o = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id).ok_or_else(|| bad(cmd, "occurrence"))?;
o.grounded = want.unwrap_or(!o.grounded);
let g = o.grounded;
Ok(json!({"occurrence": id, "grounded": g}))
reply(GroundResult { occurrence: id, grounded: g, param_warnings: Vec::new() })
}
fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -882,7 +944,7 @@ fn capture(s: &mut Session, _p: &Value) -> Result<Value> {
}
}
s.revision += 1;
Ok(json!({ "captured": n }))
reply(CapturePositionResult { captured: n, param_warnings: Vec::new() })
}
fn revert(s: &mut Session, p: &Value) -> Result<Value> {
@@ -904,7 +966,7 @@ fn revert(s: &mut Session, p: &Value) -> Result<Value> {
}
let n = before - s.pending_moves.len();
s.revision += 1;
Ok(json!({ "reverted": n }))
reply(RevertPositionResult { reverted: n, param_warnings: Vec::new() })
}
/// The active component's frame from world coordinates (for new features' geometry).
+12 -2
View File
@@ -4,8 +4,9 @@ use solidcraft_geom::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use crate::params::bad;
use crate::schema::component::PasteNewResult;
use crate::{Result, Sel, Session};
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
const CMD: &str = "component.paste_new";
@@ -793,7 +794,16 @@ pub(super) fn build(
}
}
scratch.selection = vec![Sel::Occurrence { path: clone_path }];
let out = json!({"component":root,"occurrence":root_occurrence,"features":new_ids,"components":components.len(),"bodies":expected_count,"frozen_bodies":frozen,"notices":notices});
let out = super::component::reply(PasteNewResult {
component: root,
occurrence: root_occurrence,
features: new_ids,
components: components.len(),
bodies: expected_count,
frozen_bodies: frozen,
notices,
param_warnings: Vec::new(),
})?;
Ok((scratch, out))
}
+10 -2
View File
@@ -4,11 +4,12 @@
//! (`joint.reach` moves those as far as their joints allow). A chain of joints joined to nothing
//! fixed moves as one, so its joints stay as they are.
use serde_json::{Value, json};
use serde_json::Value;
use solidcraft_doc::{mat_inverse, mat_mul};
use super::{move_vector, occurrence, occurrence_path, translation, valid_position};
use crate::params::bad;
use crate::schema::component::DragResult;
use crate::{EngineError, Result, Session};
#[cfg(test)]
@@ -158,5 +159,12 @@ pub(super) fn drag(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let continuous = p.get("continuous").and_then(Value::as_bool).unwrap_or(false);
Ok(json!({"occurrence": path.last(), "path": path, "moved": moved, "translate": [t.x, t.y, t.z], "continuous": continuous}))
super::reply(DragResult {
occurrence: path.last().copied().unwrap_or_default(),
path,
moved,
translate: [t.x, t.y, t.z],
continuous,
param_warnings: Vec::new(),
})
}
+7 -11
View File
@@ -2,14 +2,16 @@
use solidcraft_geom::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use serde_json::Value;
use solidcore_record::Vec3;
use solidcraft_doc::{Document, Mat};
use solidcraft_geom::Aabb3;
use solidcraft_geom::inertia::{self, Tensor3};
use crate::params::bad;
use crate::schema::component::{BodyProperties, BoundingBox, InertiaAbout, PhysicalProperties, PropertiesResult, TotalProperties};
use crate::schema::component::{
BodyProperties, BoundingBox, InertiaAbout, OccurrencePropertiesResult, PhysicalProperties, PropertiesResult, TotalProperties,
};
use crate::{Result, Session};
fn descendants(p: &Value, cmd: &str) -> Result<bool> {
@@ -104,10 +106,9 @@ pub(super) fn occurrence(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "occurrence has multiple placements; supply its `path`")),
}
};
let mut out = properties(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
out["occurrence"] = json!(id);
out["name"] = json!(doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.as_str()));
Ok(out)
let properties = measure(s, &doc, &path, descendants(p, cmd)?, about(p, cmd)?, cmd)?;
let name = doc.occurrences.iter().find(|o| o.id == id).map(|o| o.name.clone());
super::reply(OccurrencePropertiesResult { occurrence: id, name, properties, param_warnings: Vec::new() })
}
/// The linear part of `m` is a rotation (no scale, shear or mirror), to rounding.
@@ -199,11 +200,6 @@ fn measure_placed(body: &solidcraft_doc::ModelBody, transform: Mat) -> Result<Pl
})
}
/// [`measure`] as the JSON `occurrence.properties` adds its own keys to.
fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<Value> {
super::reply(measure(s, doc, selected, descendants, about, cmd)?)
}
fn measure(s: &Session, doc: &Document, selected: &[u64], descendants: bool, about: Option<Vec3>, cmd: &str) -> Result<PhysicalProperties> {
let state = s.model.state();
let mut bodies = Vec::new();
+2 -2
View File
@@ -1,6 +1,6 @@
//! Atomic timeline ownership changes, preserving the selected world geometry.
use super::*;
use crate::schema::component::{BodyComponent, FromBodiesResult};
use crate::schema::component::{BodyComponent, FromBodiesResult, MoveBodiesResult};
use solidcraft_doc::BodyName;
use std::collections::{BTreeMap, BTreeSet};
@@ -188,7 +188,7 @@ fn move_at_boundary(s: &mut Session, p: &Value, allow_boundary: bool) -> Result<
features.push(transfer(&mut scratch, name, to, &frames, cmd)?);
}
finish(s, &mut scratch, &features, cmd)?;
Ok(json!({"bodies":bodies,"component":to,"features":features}))
super::reply(MoveBodiesResult { bodies, component: to, features, param_warnings: Vec::new() })
}
#[cfg(test)]
+102 -60
View File
@@ -8,13 +8,17 @@ use solidcraft_doc::{Feature, FeatureKind};
use super::CommandSpec;
use crate::params::{bad, bool_, name_or_id_values, num, str_};
use crate::schema::TypedContract;
use crate::schema::edit::{
FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams, TimelineComputeAllResult,
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
BrokenFeature, FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams,
TimelineComputeAllResult, TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams,
TimelineEditResult, TimelineGroupMade, TimelineGroupParams, TimelineGroupRenamed, TimelineInspectParams, TimelineInspectResult,
TimelineMarkerResult, TimelineMoveManyParams, TimelineMoveToGroupParams, TimelineMoved, TimelineMovedToGroup, TimelineRedefineParams,
TimelineRedefined, TimelineRenameGroupParams, TimelineRenameParams, TimelineRenameResult, TimelineReorderManyParams, TimelineReorderParams,
TimelineReordered, TimelineReorderedMany, TimelineRollToParams, TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
TimelineUngroupParams, TimelineUngrouped,
};
use crate::schema::select::{PartialPick, SelectClearParams, SelectClearResult, SelectItem, SelectSetParams, SelectSetResult, read_item};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
@@ -87,18 +91,35 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&RENAME_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineRenameResult>)
.params("feature: id|name, name (references to it follow)"),
CommandSpec::new("timeline.group", "New Feature Folder", group)
CommandSpec::new("timeline.group", "New Feature Folder", crate::checked!("timeline.group", group))
.icon("folder")
.doc_only()
.contract(&GROUP_CONTRACT)
.output(output_schema_for::<TimelineGroupMade>)
.params("name?; features?: [id|name]; joints?: [id|name]; groups?: [folder ids]; parent?: folder id (features and joints leave any other timeline folder)"),
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", ungroup).icon("folder")
CommandSpec::new("timeline.ungroup", "Remove Feature Folders", crate::checked!("timeline.ungroup", ungroup))
.icon("folder")
.doc_only()
.contract(&UNGROUP_CONTRACT)
.output(output_schema_for::<TimelineUngrouped>)
.params("group: id | groups: [ids] (features and joints remain in the timeline)"),
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", rename_group).icon("rename").doc_only().params("group: id, name (renames the folder holding features and joints)"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", move_to_group).icon("folder")
CommandSpec::new("timeline.rename_group", "Rename Feature Folder", crate::checked!("timeline.rename_group", rename_group))
.icon("rename")
.doc_only()
.contract(&RENAME_GROUP_CONTRACT)
.output(output_schema_for::<TimelineGroupRenamed>)
.params("group: id, name (renames the folder holding features and joints)"),
CommandSpec::new("timeline.move_to_group", "Move Features to Folder", crate::checked!("timeline.move_to_group", move_to_group))
.icon("folder")
.doc_only()
.contract(&MOVE_TO_GROUP_CONTRACT)
.output(output_schema_for::<TimelineMovedToGroup>)
.params("features?: [id|name], joints?: [id|name], groups?: [ids], group?: id (omit or null to move to the timeline root)"),
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
CommandSpec::new("timeline.move_many", "Move Timeline Items", crate::checked!("timeline.move_many", move_many))
.icon("reorder")
.contract(&MOVE_MANY_CONTRACT)
.output(output_schema_for::<TimelineMoved>)
.params(
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
),
CommandSpec::new("timeline.rollback", "Move Timeline Marker", crate::checked!("timeline.rollback", rollback))
@@ -111,12 +132,22 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ROLL_TO_CONTRACT)
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
CommandSpec::new("timeline.reorder", "Reorder Feature", crate::checked!("timeline.reorder", reorder))
.icon("reorder")
.contract(&REORDER_CONTRACT)
.output(output_schema_for::<TimelineReordered>)
.params("feature: id|name, position: its new index, active?: bool (true = it ends up before the history marker, false = behind it; other features keep their state); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke → feature, position: its index now, from: its index before, moved: bool, note?: why nothing moved, or that a position past the end meant the last slot"),
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
CommandSpec::new("timeline.reorder_many", "Reorder Features", crate::checked!("timeline.reorder_many", reorder_many))
.icon("reorder")
.contract(&REORDER_MANY_CONTRACT)
.output(output_schema_for::<TimelineReorderedMany>)
.params(
"features: [id|name], position: insertion slot in the current history; selected features keep their relative order; active?: bool (as timeline.reorder's); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke",
),
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
CommandSpec::new("timeline.redefine", "Edit Feature", crate::checked!("timeline.redefine", redefine))
.icon("edit")
.contract(&REDEFINE_CONTRACT)
.output(output_schema_for::<TimelineRedefined>)
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
CommandSpec::new("timeline.dependents", "Feature Dependents", crate::checked!("timeline.dependents", dependents))
.icon("graph")
@@ -152,16 +183,24 @@ pub static COMMANDS: &[CommandSpec] = &[
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
CommandSpec::new("select.set", "Select", select_set).icon("select")
CommandSpec::new("select.set", "Select", crate::checked!("select.set", select_set)).icon("select")
.noundo()
.contract(&SELECT_SET_CONTRACT)
.output(output_schema_for::<SelectSetResult>)
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool; face/edge: body plus point [x,y,z] in world mm or index (both also accepted); a point uses Measure's pick tolerance, an index supplies a point on the item; invalid partial picks leave the selection unchanged"),
CommandSpec::new("select.filter", "Selection filter", select_filter).icon("select").noundo().doc_only()
.params("mode: body|face|component → validated UI pick granularity; headless echo only, no document edits or undo"),
CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(),
CommandSpec::new("select.clear", "Clear Selection", crate::checked!("select.clear", select_clear))
.icon("select")
.noundo()
.contract(&SELECT_CLEAR_CONTRACT)
.output(output_schema_for::<SelectClearResult>),
CommandSpec::new("select.same_look", "Select Same Look", select_same_look).icon("select").noundo().doc_only()
.params("body?: world body name, face?: face index; defaults to the current body/face selection → selects objects using the resolved appearance"),
];
const SELECT_SET_CONTRACT: TypedContract<SelectSetParams> = TypedContract::new();
const SELECT_CLEAR_CONTRACT: TypedContract<SelectClearParams> = TypedContract::new();
const COMPUTE_ALL_CONTRACT: TypedContract<TimelineComputeAllParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<TimelineDeleteParams> = TypedContract::new();
const RENAME_CONTRACT: TypedContract<TimelineRenameParams> = TypedContract::new();
@@ -171,6 +210,14 @@ const DEPENDENTS_CONTRACT: TypedContract<TimelineDependentsParams> = TypedContra
const SUPPRESS_CONTRACT: TypedContract<TimelineSuppressParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<TimelineEditParams> = TypedContract::new();
const INSPECT_CONTRACT: TypedContract<TimelineInspectParams> = TypedContract::new();
const GROUP_CONTRACT: TypedContract<TimelineGroupParams> = TypedContract::new();
const UNGROUP_CONTRACT: TypedContract<TimelineUngroupParams> = TypedContract::new();
const RENAME_GROUP_CONTRACT: TypedContract<TimelineRenameGroupParams> = TypedContract::new();
const MOVE_TO_GROUP_CONTRACT: TypedContract<TimelineMoveToGroupParams> = TypedContract::new();
const MOVE_MANY_CONTRACT: TypedContract<TimelineMoveManyParams> = TypedContract::new();
const REORDER_CONTRACT: TypedContract<TimelineReorderParams> = TypedContract::new();
const REORDER_MANY_CONTRACT: TypedContract<TimelineReorderManyParams> = TypedContract::new();
const REDEFINE_CONTRACT: TypedContract<TimelineRedefineParams> = TypedContract::new();
const PLAY_CONTRACT: crate::schema::TypedContract<crate::schema::edit::TimelinePlayParams> = crate::schema::TypedContract::new();
fn play(_s: &mut Session, p: &Value) -> Result<Value> {
@@ -608,7 +655,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
return Err(not_contiguous(&folders, broken, cmd));
}
s.doc_mut().timeline_groups = folders;
Ok(json!({"group": id, "name": name, "parent": parent, "groups": groups}))
reply(cmd, TimelineGroupMade { group: id, name, parent, groups, param_warnings: Vec::new() })
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
@@ -665,7 +712,7 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"groups": ids}))
reply(cmd, TimelineUngrouped { groups: ids, param_warnings: Vec::new() })
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
@@ -678,7 +725,7 @@ fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(group) = s.doc_mut().timeline_groups.iter_mut().find(|group| group.id == id) {
group.name = name.to_string();
}
Ok(json!({"group": id, "name": name}))
reply(cmd, TimelineGroupRenamed { group: id, name: name.to_string(), param_warnings: Vec::new() })
}
fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
@@ -715,7 +762,7 @@ fn move_to_group(s: &mut Session, p: &Value) -> Result<Value> {
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
*s.doc_mut() = next;
Ok(json!({"features": features, "joints": joints, "groups": groups, "group": target}))
reply(cmd, TimelineMovedToGroup { features, joints, groups, group: target, param_warnings: Vec::new() })
}
fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
@@ -761,7 +808,7 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
}
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
reply(cmd, TimelineMoved { features: ordered, groups, group: target, position: slot, param_warnings: Vec::new() })
}
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1408,20 +1455,10 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
/// an existing index-and-point pair. Unlike Measure, selection keeps the exact face/edge index:
/// it must not merge a cylinder's patches or a circular rim's arcs into a different item.
fn selection_item(s: &Session, value: Value) -> Result<Sel> {
use solidcore_record::Vec3;
let kind = value.get("type").and_then(Value::as_str);
if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) {
return serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()));
}
let face = kind == Some("face");
#[derive(serde::Deserialize)]
struct Pick {
body: String,
index: Option<usize>,
point: Option<Vec3>,
}
let Pick { body, index, point } = serde_json::from_value(value).map_err(|e| bad("select.set", e.to_string()))?;
let (face, PartialPick { body, index, point }) = match read_item(value).map_err(|e| bad("select.set", e))? {
SelectItem::Exact(sel) => return Ok(sel),
SelectItem::Pick { face, pick } => (face, pick),
};
let state = s.world_state()?;
let b = state.body(&body).ok_or_else(|| bad("select.set", format!("no body `{body}`")))?;
let missing = || bad("select.set", format!("no {} of `{body}` at the given index or point", if face { "face" } else { "edge" }));
@@ -1496,7 +1533,7 @@ fn select_set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
s.refresh_display();
Ok(json!({"selected": s.selection.len()}))
reply("select.set", SelectSetResult { selected: s.selection.len(), param_warnings: Vec::new() })
}
fn select_filter(_s: &mut Session, p: &Value) -> Result<Value> {
@@ -1553,7 +1590,7 @@ fn select_same_look(s: &mut Session, p: &Value) -> Result<Value> {
fn select_clear(s: &mut Session, _p: &Value) -> Result<Value> {
s.selection.clear();
s.refresh_display();
Ok(json!({"selected": 0}))
reply("select.clear", SelectClearResult { selected: 0, param_warnings: Vec::new() })
}
/// Features that worked in `before` and fail in `after`: (name, error).
@@ -1660,7 +1697,8 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let active = active_param(p, cmd)?;
if from == to && active.is_none() {
let why = capped.map_or_else(|| format!("it is already at position {to}"), |c| format!("{c}, where it already is"));
return Ok(json!({"feature": id, "position": to, "from": from, "moved": false, "note": format!("nothing moved: {why}")}));
let note = Some(format!("nothing moved: {why}"));
return reply(cmd, TimelineReordered { feature: id, position: to, from, moved: false, note, broken: None, param_warnings: Vec::new() });
}
let mut next = (*s.doc).clone();
let f = next.features.remove(from);
@@ -1705,20 +1743,22 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
}
*s.doc_mut() = next;
}
let mut out = json!({"feature": id, "position": now, "from": from, "moved": now != from});
// With `active`, a feature left in place may still cross the history marker: say which.
let stayed = (now == from).then(|| {
let what = if s.doc.marker == marker { "nothing moved" } else { "only the history marker moved" };
format!("{what}: {name} is already at position {now}")
});
let note: Vec<String> = [capped, stayed].into_iter().flatten().collect();
if !note.is_empty() {
out["note"] = json!(note.join("; "));
}
if allow {
out["broken"] = broken_json(&broken);
}
Ok(out)
let out = TimelineReordered {
feature: id,
position: now,
from,
moved: now != from,
note: (!note.is_empty()).then(|| note.join("; ")),
broken: allow.then(|| broken_features(&broken)),
param_warnings: Vec::new(),
};
reply(cmd, out)
}
/// The optional `allow_errors` of a move: make it even when it breaks features that worked.
@@ -1731,8 +1771,8 @@ fn allow_errors(p: &Value, cmd: &str) -> Result<bool> {
}
/// The features a move broke, as `[{feature, error}]`.
fn broken_json(broken: &[(String, String)]) -> Value {
broken.iter().map(|(feature, error)| json!({"feature": feature, "error": error})).collect()
fn broken_features(broken: &[(String, String)]) -> Vec<BrokenFeature> {
broken.iter().map(|(feature, error)| BrokenFeature { feature: feature.clone(), error: error.clone() }).collect()
}
/// The optional `active` of a move: whether the moved features end up before the history marker.
@@ -1778,11 +1818,8 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
}
*s.doc_mut() = next;
}
let mut out = json!({"features": ordered, "position": slot.min(s.doc.features.len())});
if allow {
out["broken"] = broken_json(&broken);
}
Ok(out)
let position = slot.min(s.doc.features.len());
reply(cmd, TimelineReorderedMany { features: ordered, position, broken: allow.then(|| broken_features(&broken)), param_warnings: Vec::new() })
}
/// The reordered design, the moved features in order, and the model the move was checked on
@@ -2093,15 +2130,20 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
if rolled_back { r.get("warning").and_then(Value::as_str).map(str::to_string) } else { s.model.result(id).and_then(|r| r.warning.clone()) };
// Report the trial's rebuild, plus anything the session still had to compute itself (a kept
// result its budget dropped); `s.model`'s own counters would only report the reuse (#5694).
let mut reply = json!({"feature": id, "recomputed": rebuilt + s.model.last_recomputed, "restored": reused, "rolled_back": rolled_back});
if let Some(warning) = warning {
reply["warning"] = json!(warning);
}
if rolled_back {
let note = rolled_back.then(|| {
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
reply["note"] = json!(format!("{name} is after the history marker, so it is not built: move the marker past it to build it"));
}
Ok(reply)
format!("{name} is after the history marker, so it is not built: move the marker past it to build it")
});
let out = TimelineRedefined {
feature: id,
recomputed: rebuilt + s.model.last_recomputed,
restored: reused,
rolled_back,
warning,
note,
param_warnings: Vec::new(),
};
reply(cmd, out)
}
fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
+355 -67
View File
@@ -87,8 +87,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("F")
.contract(&FILLET_CONTRACT)
.output(output_schema_for::<FilletResult>)
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them); type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?: the body the edges are on (without it, each point is looked up on every placed body, shown or hidden, and a point on edges of 2 bodies is refused, naming them)"),
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
.at("SOLID", "MODIFY")
.icon("combine")
@@ -177,7 +177,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet); body?: the body the face or edges are on (for edges, as for solid.fillet)"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place); occurrences?: [occurrence paths, root first] (only with copy: each component is copied as an independent component, as Paste New copies it, and the copy moves by the same translate, axis and angle in the world; the reply lists the copies in occurrences)"),
];
@@ -1554,20 +1554,131 @@ fn prim_cone(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::Cone { base, axis: Dir(axis), radius, top_radius, height, operation: operation(p, cmd)? })
}
/// Edge references → points on the edges.
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
/// What [`edge_points`] found: a point on each edge, in its body's frame, and the body the
/// feature keeps (`None`: each edge is looked up on its own).
struct EdgePicks {
points: Vec<Vec3>,
body: Option<String>,
}
/// A body as a pick is looked up on: the model body, the name it is shown by, and the frame it
/// is placed by and its inverse.
struct PickRow {
body: usize,
name: String,
frame: solidcraft_doc::Mat,
inverse: solidcraft_doc::Mat,
}
/// What one body's edges near a pick are: the point in its frame, its distance from the pick on
/// the exact curves and the edge there, whether any edge within reach is not a seam, and the
/// edges within reach (polyline distance, seam, the direction leaving).
struct RowHit {
row: usize,
q: Vec3,
d: f64,
edge: Option<usize>,
edge_like: bool,
near: Vec<(f64, bool, Option<Vec3>)>,
}
/// How many bodies a refusal names before "and N more".
const NAMED_BODIES: usize = 8;
/// A body's size, its edges' polylines and which of them are seams.
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>);
/// How far a polyline is from `q`.
fn polyline_dist(points: &[Vec3], q: Vec3) -> f64 {
points.windows(2).filter_map(|w| Some(q.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min)
}
/// How far from an edge of a body this big a pick still means it.
fn edge_reach(size: f64) -> f64 {
size * 0.05 + 1e-3
}
/// Edge references → points on the edges, each in the frame of the body it lies on.
///
/// A point is looked up where it was picked, on every placed body, shown or hidden: a part
/// whose edge is in the same place only in its own frame is not in the way, and two bodies
/// with an edge there make it ambiguous. The polylines short-list the edges; which body a pick
/// means is decided on the exact curves.
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`edges` must be a list"))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "select 1…1000 edges"));
}
let verb = if cmd == "solid.chamfer" { "chamfer" } else { "round" };
let st = s.model.state();
// A picked point must lie on an edge (within the distance the kernel re-finds an edge from)
// of the body the feature names, or of any body: a pick far from every edge would round
// whichever edge is nearest (#783). Each body's edges are worked out once.
let named = str_(p, "body").filter(|n| st.body(n).is_some());
// Per body: its size, its edges' polylines and which of them are seams.
let mut candidates: Option<Vec<(f64, Vec<Vec<Vec3>>, Vec<bool>)>> = None;
// whichever edge is nearest (#783).
let given = str_(p, "body");
let named = given.and_then(|n| st.bodies.iter().position(|b| b.name == n));
// A pick's own body is the feature's, which keeps one.
let agrees = |i: usize| -> Result<usize> {
match given {
Some(g) if named != Some(i) => {
let n = st.bodies.get(i).map_or("", |b| b.name.as_str());
Err(bad(cmd, format!("an edge names `{n}`, but `body` is `{g}`: give one")))
}
_ => Ok(i),
}
};
let identity = |i: usize, b: &solidcraft_doc::ModelBody| PickRow {
body: i,
name: b.name.clone(),
frame: solidcraft_doc::IDENTITY,
inverse: solidcraft_doc::IDENTITY,
};
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
let local: Vec<PickRow> = st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()).map(|(i, b)| identity(i, b)).collect();
let placed = crate::frames::placed(s);
// Worked out for the first pick looked up where it was picked.
let world: std::cell::OnceCell<Vec<PickRow>> = std::cell::OnceCell::new();
let world_rows = || {
world.get_or_init(|| {
let index: std::collections::BTreeMap<&str, usize> = st.bodies.iter().enumerate().map(|(i, b)| (b.definition_name(), i)).collect();
s.body_frames()
.iter()
.filter_map(|(name, own, frame)| {
let body = *index.get(own.as_str())?;
let inverse = solidcraft_doc::mat_inverse(frame)?;
(!st.bodies.get(body)?.body.is_mesh()).then(|| PickRow { body, name: name.clone(), frame: *frame, inverse })
})
.collect()
})
};
// Per body, worked out once.
let mut shapes: std::collections::BTreeMap<usize, EdgeShape> = std::collections::BTreeMap::new();
let shape_of = |i: usize, shapes: &mut std::collections::BTreeMap<usize, EdgeShape>| {
if shapes.contains_key(&i) {
return;
}
let Some(b) = st.bodies.get(i) else { return };
let size = b.body.size();
let tol = (size * 2e-3).max(1e-3);
// The display mesh flags seams (a smooth surface meeting itself, as down the side
// of a cylinder), which nobody means by a pick where they end.
let shape = match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
// An edge where the faces meet tangentially (the spans of a spline wall
// meeting) can't be blended either: nobody means it by a pick.
Ok(m) => Some((
size,
m.edges,
m.seams.iter().zip(&m.smooth_edges).map(|(s, t)| *s || *t).chain(m.seams.iter().skip(m.smooth_edges.len()).copied()).collect(),
)),
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
};
if let Some(shape) = shape {
shapes.insert(i, shape);
}
};
let mut out = Vec::new();
// Per pick: the body it was found on, where it was picked, and how far from that body's edge.
let mut found: Vec<(usize, String, Vec3, Vec3, f64)> = Vec::new();
let mut seen: Vec<Vec3> = Vec::new();
for e in a {
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
EdgeRefForm::Point(pt) => pt,
@@ -1576,57 +1687,139 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let tol = (b.body.size() * 2e-3).max(1e-3);
let edges = b.body.edges(tol)?;
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
let mid = edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid;
if let Some(i) = st.bodies.iter().position(|m| m.name == b.name) {
found.push((agrees(i)?, b.name.clone(), mid, mid, 0.0));
}
out.push(mid);
continue;
}
};
let bodies = candidates.get_or_insert_with(|| {
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
st.bodies
// The nth pick seen at this point is the nth remembered there.
let nth = seen.iter().filter(|q| **q == pt).count();
seen.push(pt);
let picked = crate::frames::picked_world(pt, nth).filter(|_| placed);
let shown = picked.unwrap_or(pt);
let own = match e.get("body").and_then(Value::as_str) {
Some(n) => Some(agrees(st.bodies.iter().position(|b| b.name == n).ok_or_else(|| bad(cmd, format!("no body `{n}`")))?)?),
None => named,
};
// Named: on that body only, in its frame. Else where it was picked, on every placed body;
// a command run by another without the world point, or a design with no placement to look
// through, sees the bodies in their own frames.
let (w, rows): (Vec3, Vec<&PickRow>) = match (own, picked) {
(Some(i), _) => (pt, local.iter().filter(|r| r.body == i).collect()),
(None, Some(w)) if !world_rows().is_empty() => (w, world_rows().iter().collect()),
_ => (pt, local.iter().collect()),
};
let q_of = |r: &PickRow| {
let q = solidcraft_doc::apply_point(&r.inverse, w);
(q.x.is_finite() && q.y.is_finite() && q.z.is_finite()).then_some(q)
};
// Only bodies whose box (grown by the reach) holds the pick: an edge lies in its body's box.
let culled: Vec<(usize, Vec3)> = rows
.iter()
.enumerate()
.filter_map(|(k, r)| {
let b = st.bodies.get(r.body)?;
let q = q_of(r)?;
b.outline_bounds().inflated(edge_reach(b.body.size())).contains(q).then_some((k, q))
})
.collect();
for (k, _) in &culled {
if let Some(r) = rows.get(*k) {
shape_of(r.body, &mut shapes);
}
}
let mut hits: Vec<RowHit> = Vec::new();
for (k, q) in culled {
let Some(r) = rows.get(k) else { continue };
let (Some((size, edges, seams)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
let chord = (size * 2e-3).max(1e-3);
let mut near = Vec::new();
let mut candidates = Vec::new();
for (i, points) in edges.iter().enumerate() {
let d = polyline_dist(points, q);
if d <= edge_reach(*size) {
let seam = seams.get(i).copied().unwrap_or(false);
near.push((d, seam, leaving(points, q)));
candidates.push((i, d, seam));
}
}
if near.is_empty() {
continue;
}
// Seams count only where nothing else is near.
let edge_like = candidates.iter().any(|(_, _, seam)| !seam);
let candidates: Vec<(usize, f64)> = candidates.into_iter().filter(|(_, _, seam)| !edge_like || !seam).map(|(i, d, _)| (i, d)).collect();
let polyline = candidates.iter().map(|(_, d)| *d).fold(f64::INFINITY, f64::min);
let (d, edge) = candidates
.iter()
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
.filter_map(|b| {
let size = b.body.size();
let tol = (size * 2e-3).max(1e-3);
// The display mesh flags seams (a smooth surface meeting itself, as down the side
// of a cylinder), which nobody means by a pick where they end.
match b.body.mesh(&solidcraft_kernel::MeshTolerance::chord(tol)) {
// An edge where the faces meet tangentially (the spans of a spline wall
// meeting) can't be blended either: nobody means it by a pick.
Ok(m) => Some((
size,
m.edges,
m.seams
.iter()
.zip(&m.smooth_edges)
.map(|(s, t)| *s || *t)
.chain(m.seams.iter().skip(m.smooth_edges.len()).copied())
.collect(),
)),
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
}
})
.collect()
});
.filter(|(_, d)| *d <= polyline + 2.0 * chord)
.map(|(i, d)| (edge_foot(&b.body, *i, q).map_or(*d, |(_, foot)| solidcraft_doc::apply_point(&r.frame, foot).dist(w)), *i))
.fold((f64::INFINITY, None), |best, (d, i)| if d < best.0 { (d, Some(i)) } else { best });
hits.push(RowHit { row: k, q, d, edge, edge_like, near });
}
if hits.is_empty() {
if rows.is_empty() {
out.push(pt);
continue;
}
// How far the nearest edge is, on every body the pick was looked up on.
let mut nearest = f64::INFINITY;
for r in &rows {
let Some(q) = q_of(r) else { continue };
shape_of(r.body, &mut shapes);
let Some((_, edges, _)) = shapes.get(&r.body) else { continue };
nearest = edges.iter().map(|points| polyline_dist(points, q)).fold(nearest, f64::min);
}
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", shown.x, shown.y, shown.z)));
}
// A body with only a seam there has no edge to blend, where another body has one.
if hits.iter().any(|h| h.edge_like) {
hits.retain(|h| h.edge_like);
}
// Bodies with an edge as near as the nearest one's: the pick is on both. A pick off its
// curve by e widens the window to about 2e. The same body placed twice, at the same
// edge or point of its frame, is one edge.
let d_min = hits.iter().map(|h| h.d).fold(f64::INFINITY, f64::min);
let mut groups: Vec<&RowHit> = Vec::new();
for h in hits.iter().filter(|h| h.d <= 2.0 * d_min + 1e-3) {
let Some(r) = rows.get(h.row) else { continue };
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _)| *size);
let same = |g: &&RowHit| {
rows.get(g.row).is_some_and(|o| o.body == r.body) && ((g.edge.is_some() && g.edge == h.edge) || g.q.dist(h.q) <= 1e-6 * size.max(1.0))
};
if !groups.iter().any(same) {
groups.push(h);
}
}
if groups.len() > 1 {
let names: Vec<String> = groups.iter().filter_map(|g| rows.get(g.row).map(|r| r.name.clone())).collect();
let hidden: String = names.iter().filter(|n| !s.body_visible(n)).map(|n| format!(" ({n} is hidden)")).collect();
return Err(bad(
cmd,
format!(
"[{}, {}, {}] lies on edges of {} bodies, {}: name the one to {verb} with `body`{hidden}",
shown.x,
shown.y,
shown.z,
names.len(),
and_list(&names, NAMED_BODIES)
),
));
}
let Some(hit) = hits.iter().filter(|h| h.d <= d_min).chain(hits.iter()).next() else { continue };
let Some(row) = rows.get(hit.row) else { continue };
// A point where several edges meet (a corner) is ambiguous: the kernel would round one of
// them, picked silently (#5173). Edges that carry straight on through the point (the two
// halves of a circle) are one edge to whoever picked it, and seams and tangent joins don't
// count. Directions
// come from the polylines' end chords, which lean off a curve's tangent: within about 45°
// of opposite counts as carrying on (edges at a real corner are far from opposite).
let mut nearest = f64::INFINITY;
let mut near = Vec::new();
for (size, edges, seams) in bodies.iter() {
for (i, points) in edges.iter().enumerate() {
let d = points.windows(2).filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
if d <= size * 0.05 + 1e-3 {
near.push((d, seams.get(i).copied().unwrap_or(false), leaving(points, pt)));
}
}
}
let matches = near.len();
let mut meeting: Vec<Option<Vec3>> = near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
// count. Directions come from the polylines' end chords, which lean off a curve's
// tangent: within about 45° of opposite counts as carrying on (edges at a real corner are
// far from opposite).
let nearest = hit.near.iter().map(|(d, _, _)| *d).fold(f64::INFINITY, f64::min);
let mut meeting: Vec<Option<Vec3>> = hit.near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
let mut chains = 0usize;
while let Some(dir) = meeting.pop() {
chains += 1;
@@ -1639,16 +1832,107 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
if chains > 1 {
return Err(bad(
cmd,
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", pt.x, pt.y, pt.z),
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", shown.x, shown.y, shown.z),
));
}
if matches == 0 && !bodies.is_empty() {
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
}
out.push(pt);
found.push((row.body, row.name.clone(), hit.q, shown, hit.d));
out.push(hit.q);
}
Ok(out)
if given.is_some() {
return Ok(EdgePicks { points: out, body: given.map(str::to_string) });
}
let mut bodies: Vec<(usize, String)> = Vec::new();
for (i, name, ..) in &found {
if !bodies.iter().any(|(b, _)| b == i) {
bodies.push((*i, name.clone()));
}
}
let body = match bodies.as_slice() {
// One body of several: the feature keeps it, so the model never looks the edges up on
// another body that has one in the same place in its own frame.
[(i, _)] => st.bodies.get(*i).filter(|_| local.len() > 1).map(|b| b.name.clone()),
[] => None,
_ => {
// Several: each edge is looked up on its own when the model is built, on whichever
// body has an edge there in its own frame, so another body's edge there is refused.
for (i, _, q, shown, d) in &found {
for other in local.iter().filter(|r| r.body != *i) {
let Some(b) = st.bodies.get(other.body) else { continue };
let reach = edge_reach(b.body.size());
if !b.outline_bounds().inflated(reach).contains(*q) {
continue;
}
shape_of(other.body, &mut shapes);
let Some((size, edges, seams)) = shapes.get(&other.body) else { continue };
// The exact curve is no nearer than the polyline less its chord tolerance.
let chord = (size * 2e-3).max(1e-3);
let ghost = edges.iter().enumerate().filter(|(k, _)| !seams.get(*k).copied().unwrap_or(false)).any(|(k, points)| {
let poly = polyline_dist(points, *q);
poly <= reach && poly - chord <= 2.0 * d + 1e-3 && edge_foot(&b.body, k, *q).map_or(poly, |(d, _)| d) <= 2.0 * d + 1e-3
});
if ghost {
let names: Vec<String> = bodies.iter().map(|(_, n)| n.clone()).collect();
return Err(bad(
cmd,
format!(
"the picks lie on {} bodies, {}, and at [{}, {}, {}] {} has an edge in the same place in its own component: {verb} each body in a command of its own, naming it with `body`",
names.len(),
and_list(&names, NAMED_BODIES),
shown.x,
shown.y,
shown.z,
b.name
),
));
}
}
}
None
}
};
Ok(EdgePicks { points: out, body })
}
/// "A", "A and B", "A, B and C", and past `most` names "A, B and 3 more".
fn and_list(names: &[String], most: usize) -> String {
let mut shown: Vec<String> = names.iter().take(most).cloned().collect();
if names.len() > most {
shown.push(format!("{} more", names.len() - most));
}
match shown.split_last() {
Some((last, rest)) if !rest.is_empty() => format!("{} and {last}", rest.join(", ")),
Some((last, _)) => last.clone(),
None => String::new(),
}
}
/// The distance from `q` to edge `index` of `body` on its exact curve, and the point there:
/// samples, then a golden-section search between the best one's neighbours.
fn edge_foot(body: &solidcraft_kernel::Body, index: usize, q: Vec3) -> Option<(f64, Vec3)> {
const SAMPLES: usize = 32;
const STEPS: usize = 40;
let e = body.record().edges.get(index)?;
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
let [t0, t1] = e.interval;
if !(t0.is_finite() && t1.is_finite()) {
return None;
}
let at = |t: f64| c.point(t).map(|p| p.dist(q)).filter(|d| d.is_finite());
let step = (t1 - t0) / SAMPLES as f64;
let (k, _) = (0..=SAMPLES).filter_map(|k| Some((k, at(t0 + step * k as f64)?))).min_by(|a, b| a.1.total_cmp(&b.1))?;
let (mut lo, mut hi) = (t0 + step * k.saturating_sub(1) as f64, t0 + step * (k + 1).min(SAMPLES) as f64);
let ratio = (5.0_f64.sqrt() - 1.0) / 2.0;
for _ in 0..STEPS {
let (a, b) = (hi - ratio * (hi - lo), lo + ratio * (hi - lo));
if at(a).unwrap_or(f64::INFINITY) <= at(b).unwrap_or(f64::INFINITY) {
hi = b;
} else {
lo = a;
}
}
let foot = c.point((lo + hi) / 2.0)?;
let d = foot.dist(q);
d.is_finite().then_some((d, foot))
}
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
@@ -1687,7 +1971,11 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.press_pull";
let distance = req_expr(cmd, p, "distance")?;
if p.get("edges").is_some() {
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
let mut q = json!({"edges": p.get("edges"), "radius": distance});
if let Some(body) = p.get("body").filter(|b| b.is_string()) {
q["body"] = body.clone();
}
return fillet(s, &q);
}
let face = p.get("face").ok_or_else(|| bad(cmd, "needs `face` or `edges`"))?;
let d = s.doc.eval(&distance, Kind::Length).map_err(|e| bad(cmd, format!("distance: {e}")))?;
@@ -1737,7 +2025,7 @@ fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.fillet";
let edges = edge_points(s, p, cmd)?;
let picks = edge_points(s, p, cmd)?;
let radius = req_expr(cmd, p, "radius")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
@@ -1840,9 +2128,9 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
s,
p,
FeatureKind::Fillet {
edges: edges.into_iter().map(Position).collect(),
edges: picks.points.into_iter().map(Position).collect(),
radius,
body: str_(p, "body").map(BodyName::from),
body: picks.body.map(BodyName::from),
style,
fit,
corner,
@@ -1866,7 +2154,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.chamfer";
let edges = edge_points(s, p, cmd)?;
let picks = edge_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let (distance2, angle) = (expr(p, "distance2"), expr(p, "angle"));
@@ -1884,12 +2172,12 @@ fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
s,
p,
FeatureKind::Chamfer {
edges: edges.into_iter().map(Position).collect(),
edges: picks.points.into_iter().map(Position).collect(),
distance,
distance2,
angle,
flip,
body: str_(p, "body").map(BodyName::from),
body: picks.body.map(BodyName::from),
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
},
)
+109 -69
View File
@@ -16,10 +16,12 @@ use solidcraft_geom::Aabb3;
use super::CommandSpec;
use crate::params::{bad, bool_, num, str_, vec3};
use crate::schema::joints::{
AsBuiltJointParams, CamLift, ContactStop, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams, DriveJointResult,
EditJointResult, FloatingChain, HeldValue, InspectJointParams, InspectJointResult, JointAddedResult, JointDeleted, JointFrame, JointLimitsParams,
JointProblem, LinkInfo, ListJointsParams, ListJointsResult, ListedJoint, ListedOrigin, LoopStop, NamedValue, OccurrenceDof, SolveJointsParams,
SolveReport, SolveResult, ValueUnit,
AsBuiltJointParams, CamLift, CamParams, CamResult, ContactStop, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult,
DriveJointParams, DriveJointResult, EditJointResult, FloatingChain, GearParams, HeldValue, InspectJointParams, InspectJointResult,
JointAddedResult, JointDeleted, JointFrame, JointLimitsParams, JointProblem, LimitSide, LimitStop, LinkInfo, ListJointsParams, ListJointsResult,
ListedJoint, ListedOrigin, LoopStop, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, NamedValue, OccurrenceDof, RackPinionParams,
ReachLoopStop, ReachParams, ReachResult, ReachedJoint, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveReport,
SolveResult, ValueUnit,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -33,12 +35,25 @@ const DELETE_CONTRACT: TypedContract<DeleteJointParams> = TypedContract::new();
const LIST_CONTRACT: TypedContract<ListJointsParams> = TypedContract::new();
const INSPECT_CONTRACT: TypedContract<InspectJointParams> = TypedContract::new();
const SOLVE_CONTRACT: TypedContract<SolveJointsParams> = TypedContract::new();
const ORIGIN_CONTRACT: TypedContract<MakeJointOriginParams> = TypedContract::new();
const RIGID_GROUP_CONTRACT: TypedContract<RigidGroupParams> = TypedContract::new();
const REACH_CONTRACT: TypedContract<ReachParams> = TypedContract::new();
const MOTION_LINK_CONTRACT: TypedContract<MotionLinkParams> = TypedContract::new();
const GEAR_CONTRACT: TypedContract<GearParams> = TypedContract::new();
const RACK_PINION_CONTRACT: TypedContract<RackPinionParams> = TypedContract::new();
const SCREW_CONTRACT: TypedContract<ScrewParams> = TypedContract::new();
const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
#[path = "joints_inspect.rs"]
mod inspect;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
.at("SOLID", "CREATE")
.icon("joint_origin")
.contract(&ORIGIN_CONTRACT)
.output(output_schema_for::<MakeJointOriginResult>)
.params(
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size; z?: [x,y,z] the frame's z instead of the face's normal, along the edge when it runs that way, x chosen from z: an edge or corner picked as it is, kept as a point where it can't be found by name) | point: [x,y,z], z?: [x,y,z]; a circle's z?: [x,y,z] (a round edge's centre with the way out of it: its end by name when that is the frame it shows, else a point there); body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
),
CommandSpec::new("joint.create", "Joint", crate::checked!("joint.create", joint))
@@ -68,7 +83,12 @@ pub static COMMANDS: &[CommandSpec] = &[
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame; one on a or b is kept on its face or hole there, so the joint follows it as the design changes; \
name?; limits?: [[min, max] | null…]; quiet?: bool",
),
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.rigid_group", "Rigid Group", crate::checked!("joint.rigid_group", rigid_group))
.at("SOLID", "ASSEMBLE")
.icon("rigid_group")
.contract(&RIGID_GROUP_CONTRACT)
.output(output_schema_for::<RigidGroupResult>)
.params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", crate::checked!("joint.drive", drive))
.at("SOLID", "ASSEMBLE")
.icon("drive")
@@ -83,33 +103,59 @@ pub static COMMANDS: &[CommandSpec] = &[
reason: why the joint can move nothing (suppressed: nothing is stored; its origins lost; its chain joined to nothing grounded → unconnected: \
the occurrence named; or, when nothing moved, the first held value's why); index?: zero-based coordinate to drive with value (the other coordinates follow); continuous?: bool (drag frame, suppress UI echo)",
),
CommandSpec::new("joint.reach", "Reach Point", reach).icon("reach").params(
CommandSpec::new("joint.reach", "Reach Point", crate::checked!("joint.reach", reach))
.icon("reach")
.contract(&REACH_CONTRACT)
.output(output_schema_for::<ReachResult>)
.params(
"occurrence: id|name; point: [x,y,z] (where its point goes, world); at?: [x,y,z] (its point, in the world where it is now; default: the centre of its bodies' box); z?: [x,y,z] (the way its axis then points); axis?: [x,y,z] (that axis, in the world now; default: its own z); joints?: [ids or names] (only these move toward the point; the other joints of a closed loop they are in follow); quiet?: bool; continuous?: bool (a frame of a drag: the answer says continuous, and the app's command line doesn't echo it). Moves the values of the joints on its chain from where they are to the nearest pose, within their limits, through motion links, keeping closed loops closed as joint.drive does (contact sets are not checked) → reached; error_mm (and error_deg with z): how close it got; point: where its point is; values: [{joint, name, values}] of the joints that moved, as joint.list gives them; moved; stopped_by_limit: [{joint, value, limit: min|max}]: the limits it rests on short of the point; stopped_by_loop: {joint}: the closed loop that stopped it (its chain alone gets closer); reason: why nothing can move",
),
CommandSpec::new("joint.motion_link", "Motion Link", motion_link)
CommandSpec::new("joint.motion_link", "Motion Link", crate::checked!("joint.motion_link", motion_link))
.at("SOLID", "ASSEMBLE")
.icon("motion_link")
.contract(&MOTION_LINK_CONTRACT)
.output(output_schema_for::<SolveResult>)
.params("a, b: joints; ratio (b = ratio · a + offset): b's units per a's (mm per deg for an angle driving a distance); offset?: in b's units (deg for an angle, mm for a distance, or an expression); ia?, ib?: value indices (default 0)"),
CommandSpec::new("joint.gear", "Gear", gear).at("SOLID", "ASSEMBLE").icon("gear").params(
CommandSpec::new("joint.gear", "Gear", crate::checked!("joint.gear", gear))
.at("SOLID", "ASSEMBLE")
.icon("gear")
.contract(&GEAR_CONTRACT)
.output(output_schema_for::<CouplingResult>)
.params(
"a, b: joints that turn (revolute), b driven by a; ra, rb: the gears' pitch radii (or tooth counts): mm, an expression, or \
{circle: [x,y,z], occurrence?}: the round face there, its radius (read again whenever the design changes, so the ratio follows \
edits); internal?: bool (one is a ring gear: both turn the same way; by default b turns the other way); reverse?: bool; \
link?: index (replace that motion link) → link, ratio (b's degrees per a's); b keeps where it is, also when the link replaces \
another (at the current pose)",
),
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", rack_pinion).at("SOLID", "ASSEMBLE").icon("rack_pinion").params(
CommandSpec::new("joint.rack_pinion", "Rack and Pinion", crate::checked!("joint.rack_pinion", rack_pinion))
.at("SOLID", "ASSEMBLE")
.icon("rack_pinion")
.contract(&RACK_PINION_CONTRACT)
.output(output_schema_for::<CouplingResult>)
.params(
"a, b: a joint that turns (revolute: the pinion) and one that slides (slider: the rack), in either order, b driven by a; \
radius: the pinion's pitch radius (mm, an expression, or {circle: [x,y,z], occurrence?}): the rack travels radius × the turn in \
radians; reverse?: bool; link?: index (replace that motion link) → link, ratio (mm per degree, or degrees per mm); b keeps \
where it is, also when the link replaces another (at the current pose)",
),
CommandSpec::new("joint.screw", "Screw", screw).at("SOLID", "ASSEMBLE").icon("screw").params(
CommandSpec::new("joint.screw", "Screw", crate::checked!("joint.screw", screw))
.at("SOLID", "ASSEMBLE")
.icon("screw")
.contract(&SCREW_CONTRACT)
.output(output_schema_for::<CouplingResult>)
.params(
"joint: a cylindrical joint (its turn drives its travel) | a, b: a joint that turns and one that slides, b driven by a; pitch: \
travel per turn (mm or an expression); reverse?: bool (a left-hand thread); link?: index (replace that motion link) → link, \
ratio (mm per degree, or degrees per mm); b keeps where it is, also when the link replaces another (at the current \
pose)",
),
CommandSpec::new("joint.cam", "Cam", cam_cmd).at("SOLID", "ASSEMBLE").icon("cam").params(
CommandSpec::new("joint.cam", "Cam", crate::checked!("joint.cam", cam_cmd))
.at("SOLID", "ASSEMBLE")
.icon("cam")
.contract(&CAM_CONTRACT)
.output(output_schema_for::<CamResult>)
.params(
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is, also when the link replaces another (at the current pose)",
),
CommandSpec::new("joint.limits", "Joint Limits", crate::checked!("joint.limits", limits))
@@ -306,11 +352,6 @@ fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
/// A joint's values as `joint.list` gives them: degrees for angles, mm for distances, each with
/// its name and limits. The numbers are rounded for reading (#10747); the joint keeps full
/// precision.
pub(crate) fn named_values(j: &Joint, q: &[f64]) -> Vec<Value> {
named_value_list(j, q).into_iter().map(|v| serde_json::to_value(v).unwrap_or_default()).collect()
}
/// [`named_values`], typed.
pub(crate) fn named_value_list(j: &Joint, q: &[f64]) -> Vec<NamedValue> {
j.kind
.dofs()
@@ -586,7 +627,7 @@ fn same_frame(s: &Session, o: &JointOrigin, at: Vec3, z: Vec3) -> bool {
/// The longest face name a joint origin takes. A face that cuts split and merged keeps the names
/// of the faces around it (`<name>#[<name>,…]`): several hundred characters on a part a text or
/// a pattern of holes was cut through.
const MAX_FACE_NAME: usize = 16_384;
pub(crate) const MAX_FACE_NAME: usize = 16_384;
/// The component an occurrence places (0 for the root, or an unknown occurrence).
pub(crate) fn component_of(s: &Session, occurrence: u64) -> u64 {
@@ -977,7 +1018,7 @@ fn joint_origin(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("there is already a joint origin `{name}`")));
}
s.doc_mut().assembly.origins.push(NamedOrigin { name: name.clone(), origin });
Ok(json!({"origin": name}))
reply(cmd, MakeJointOriginResult { origin: name, param_warnings: Vec::new() })
}
fn kind_param(p: &Value, cmd: &str) -> Result<JointKind> {
@@ -1098,9 +1139,9 @@ fn rigid_group(s: &mut Session, p: &Value) -> Result<Value> {
let mut made = Vec::new();
for other in ids.iter().skip(1) {
let r = as_built(s, &json!({"type": "rigid", "a": first, "b": other, "name": format!("Rigid Group {first}:{other}")}))?;
made.push(r["joint"].clone());
made.push(r["joint"].as_u64().ok_or_else(|| bad(cmd, "the joint made has no id"))?);
}
Ok(json!({"joints": made}))
reply(cmd, RigidGroupResult { joints: made, param_warnings: Vec::new() })
}
fn drive(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1263,7 +1304,7 @@ fn held_values(s: &Session, j: &Joint, requested: &[f64], driven: &[usize], r: &
}
/// Joints `joint.reach` takes at most in `joints`.
const MAX_REACH_JOINTS: usize = 1000;
pub(crate) const MAX_REACH_JOINTS: usize = 1000;
fn reach(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "joint.reach";
@@ -1314,49 +1355,54 @@ fn reach(s: &mut Session, p: &Value) -> Result<Value> {
let sol = if response_analysis(p) == joints::Analysis::Placement { place(s) } else { resolve(s) };
let t = joints::occurrence_world(&s.doc, occ);
let got = solidcraft_doc::apply_point(&t, at);
let mut out = sol_json(&sol, p);
out["occurrence"] = json!(occurrence_name(s, occ));
out["point"] = json!([got.x, got.y, got.z]);
out["error_mm"] = json!(got.dist(to));
let mut there = got.dist(to) <= joints::REACH_TOL;
let mut error_deg = None;
if let Some((a, z)) = axis {
// From the sine too: the cosine alone loses small angles to rounding.
let v = solidcraft_doc::apply_vector(&t, a);
let deg = v.cross(z).len().atan2(v.dot(z)).to_degrees();
out["error_deg"] = json!(deg);
error_deg = Some(deg);
there &= deg <= joints::REACH_TOL;
}
out["reached"] = json!(there);
let name = |id: u64| s.doc.assembly.joints.iter().find(|j| j.id == id);
// As joint.list gives them: with what motion links set.
let effective = joints::joint_values(&s.doc.assembly);
out["values"] = reached
let values = reached
.values
.keys()
.filter_map(|id| name(*id))
.map(|j| json!({"joint": j.id, "name": j.name, "values": named_values(j, effective.get(&j.id).unwrap_or(&j.values))}))
.map(|j| ReachedJoint { joint: j.id, name: j.name.clone(), values: named_value_list(j, effective.get(&j.id).unwrap_or(&j.values)) })
.collect();
out["moved"] = json!(super::motion::moved(&before, &super::motion::positions(s)).next().is_some());
out["stopped_by_limit"] = if reached.limits.is_empty() {
Value::Null
} else {
let stopped_by_limit = (!reached.limits.is_empty()).then(|| {
reached
.limits
.iter()
.filter_map(|(id, i, upper)| {
let j = name(*id)?;
Some(json!({"joint": j.name, "value": j.kind.dofs().get(*i), "limit": if *upper { "max" } else { "min" }}))
Some(LimitStop {
joint: j.name.clone(),
value: j.kind.dofs().get(*i).map(|d| (*d).to_string()),
limit: if *upper { LimitSide::Max } else { LimitSide::Min },
})
})
.collect()
});
let result = ReachResult {
solved: solve_report(&sol, p),
occurrence: occurrence_name(s, occ),
point: [got.x, got.y, got.z],
error_mm: got.dist(to),
error_deg,
reached: there,
values,
moved: super::motion::moved(&before, &super::motion::positions(s)).next().is_some(),
stopped_by_limit,
stopped_by_loop: reached.stopped_by_loop.and_then(name).map(|j| ReachLoopStop { joint: j.name.clone() }),
reason: r.err(),
continuous: (bool_(p, "continuous") == Some(true)).then_some(true),
param_warnings: Vec::new(),
};
out["stopped_by_loop"] = json!(reached.stopped_by_loop.and_then(name).map(|j| json!({"joint": j.name})));
if let Err(why) = r {
out["reason"] = json!(why);
}
if bool_(p, "continuous") == Some(true) {
out["continuous"] = json!(true);
}
Ok(out)
reply(cmd, result)
}
/// The centre of the box around an occurrence's bodies, in its own coordinates (its origin when
@@ -1387,7 +1433,7 @@ fn motion_link(s: &mut Session, p: &Value) -> Result<Value> {
let ratio = ratio * ratio_scale(s, a, ia, b, ib);
s.doc_mut().assembly.links.push(MotionLink { a, ia, b, ib, ratio, offset, coupling: None, lift: Vec::new() });
let sol = resolve(s);
Ok(sol_json(&sol, p))
reply(cmd, SolveResult { solved: solve_report(&sol, p), param_warnings: Vec::new() })
}
/// A coupling length: a number (mm), an expression (kept as typed, so it follows the
@@ -1442,7 +1488,7 @@ fn gear(s: &mut Session, p: &Value) -> Result<Value> {
internal: bool_(p, "internal").unwrap_or(false),
reverse: reverse(p),
};
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
}
fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1450,7 +1496,7 @@ fn rack_pinion(s: &mut Session, p: &Value) -> Result<Value> {
let (a, b) = joint_pair(s, p, cmd)?;
let (ia, ib) = turn_and_travel(s, a, b, cmd)?;
let c = Coupling::RackPinion { radius: size_param(s, p, "radius", cmd)?, reverse: reverse(p) };
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
}
fn screw(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1470,7 +1516,7 @@ fn screw(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let c = Coupling::Screw { pitch: size_param(s, p, "pitch", cmd)?, reverse: reverse(p) };
couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })
reply(cmd, couple(s, p, cmd, MotionLink { a, ia, b, ib, ratio: 1.0, offset: 0.0, coupling: Some(c), lift: Vec::new() })?)
}
fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1504,26 +1550,23 @@ fn cam_cmd(s: &mut Session, p: &Value) -> Result<Value> {
c.reverse = cam::default_reverse(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))? != bool_(p, "flip").unwrap_or(false);
let offset = cam::follower_offset(&s.doc, &st, &probe, &c).map_err(|e| bad(cmd, e.to_string()))?;
let reverse = c.reverse;
let mut out = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
let index = out["link"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(usize::MAX);
if let (Some(o), Some(l)) = (out.as_object_mut(), s.doc.assembly.links.get(index)) {
o.remove("ratio");
o.insert("lift".into(), lift_json(l, oscillating));
o.insert("reverse".into(), json!(reverse));
if !oscillating {
o.insert("follower_offset".into(), json!(offset));
}
}
Ok(out)
let coupled = couple(s, p, cmd, MotionLink { coupling: Some(Coupling::Cam(c)), ..probe })?;
let lift = s.doc.assembly.links.get(coupled.link).and_then(|l| lift_info(l, oscillating));
reply(
cmd,
CamResult {
solved: coupled.solved,
link: coupled.link,
lift,
reverse,
follower_offset: (!oscillating).then_some(offset),
param_warnings: Vec::new(),
},
)
}
/// A cam's follower travel over a turn of the cam: b's lowest and highest values and their
/// difference, in degrees (`angle`) or mm.
fn lift_json(l: &MotionLink, angle: bool) -> Value {
serde_json::to_value(lift_info(l, angle)).unwrap_or_default()
}
/// [`lift_json`], typed: `None` for a table that is not whole.
/// difference, in degrees (`angle`) or mm; `None` for a table that is not whole.
fn lift_info(l: &MotionLink, angle: bool) -> Option<CamLift> {
// A table that is not whole (a hand-edited file) moves nothing, and says so.
if l.lift.len() != cam::CAM_SAMPLES {
@@ -1594,7 +1637,7 @@ fn cam_profile(s: &Session, p: &Value, cmd: &str) -> Result<CamProfile> {
/// Add a coupling's motion link, or put it in place of link `link`: its ratio worked out from its
/// sizes, its offset leaving b where it is (the teeth mesh as they are now), for a new link and
/// one put in place of another alike.
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Value> {
fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<CouplingResult> {
let links = &s.doc.assembly.links;
let replace = match p.get("link") {
None => None,
@@ -1647,10 +1690,7 @@ fn couple(s: &mut Session, p: &Value, cmd: &str, mut l: MotionLink) -> Result<Va
}
};
let sol = resolve(s);
let mut out = sol_json(&sol, p);
out["link"] = json!(index);
out["ratio"] = json!(shown);
Ok(out)
Ok(CouplingResult { solved: solve_report(&sol, p), link: index, ratio: shown, param_warnings: Vec::new() })
}
fn is_angle(s: &Session, joint: u64, i: usize) -> bool {
+4 -2
View File
@@ -86,6 +86,8 @@ mod script;
mod section;
pub mod settings;
pub(crate) use clipboard::{component_path_form, copy_keys};
pub(crate) use component::matrix::{rigid as rigid_matrix, tolerance as matrix_tolerance};
pub(crate) use component::{base_transform, move_vector};
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
pub(crate) use script::step_params;
@@ -104,8 +106,8 @@ mod render_ui;
mod script_tests;
mod sheet;
pub(crate) use sheet::{
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
sheet_rule_name, then_list,
FlangeType, ThenStep, bend_table_format, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position,
perforation_pattern, sheet_points, sheet_rule_name, then_list,
};
mod silhouette;
mod sketch;
+76
View File
@@ -108,6 +108,22 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"target or legacy id, expected_revision?: reopen retained state, activate and request focus"
),
command!(
"panel.focus.next",
"fit",
"Focus Next Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.focus.previous",
"fit",
"Focus Previous Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.list",
"list",
@@ -124,6 +140,14 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"target or legacy id, destination: new_section, join_tab or relative, activate?: false, expected_revision?"
),
command!(
"panel.move.next_zone",
"move",
"Move Panel to Next Zone",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.native_close",
"restore",
@@ -180,4 +204,56 @@ pub static COMMANDS: &[CommandSpec] = &[
crate::panels::LayoutReply,
"first, second, first_size, second_size, expected_revision?: conserve adjacent dock pair extent"
),
command!(
"panel.sash.down",
"scale",
"Resize Panel Sash Down",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Down"),
command!(
"panel.sash.left",
"scale",
"Resize Panel Sash Left",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Left"),
command!(
"panel.sash.reset",
"restore",
"Reset Focused Panel Sash",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
command!(
"panel.sash.right",
"scale",
"Resize Panel Sash Right",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Right"),
command!(
"panel.sash.up",
"scale",
"Resize Panel Sash Up",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
)
.key("Up"),
command!(
"panel.toggle",
"fold",
"Toggle Focused Panel",
wire::Revision,
crate::panels::LayoutReply,
"expected_revision?: resolve the focused shared panel control in the live host"
),
];
+1 -1
View File
@@ -1721,7 +1721,7 @@ fn cpu_saved_thin_sheet_uses_its_authored_ior_beer_thickness_and_roundtrips_offl
let a = (-sigma * 2.0).exp();
let denominator = 1.0 - f * f * a * a;
let scene = f + (1.0 - f).powi(2) * (f * a * a + a) / denominator;
solidcraft_color::srgb::encode8(scene / (1.0 + scene))
solidcraft_geom::srgb::encode8(scene / (1.0 + scene))
});
assert_eq!(decoded.get_pixel(0, 0).0, [rgb[0], rgb[1], rgb[2], 255]);
assert!(Arc::ptr_eq(&before, &s.doc));
+1 -1
View File
@@ -401,7 +401,7 @@ pub(super) fn apply_appearance(s: &mut Session, params: &Value) -> Result<Value>
let value = item("surfaces", &input.id, command)?;
let lo = triple(&value["base_linear_low"], command)?;
let hi = triple(&value["base_linear_high"], command)?;
let color = std::array::from_fn::<_, 3, _>(|i| solidcraft_color::srgb::encode8((lo[i] + hi[i]) * 0.5));
let color = std::array::from_fn::<_, 3, _>(|i| crate::geom::srgb::encode8((lo[i] + hi[i]) * 0.5));
(
value,
LibraryLook {
+110 -51
View File
@@ -16,8 +16,10 @@ use super::features::{add_feature, check_expr, feature_sketch, profiles, sketch_
use crate::params::{bad, bool_, expr, expr_problem, req_expr, str_, string_list, vec3};
use crate::schema::features::FeatureResult;
use crate::schema::sheet::{
ConvertParams, FlangeParams, FoldParams, HemParams, ListedRule, ManageRulesParams, ManageRulesResult, PerforateParams, RuleFeatureError,
RuleReply, RulesParams, RulesResult, UnfoldParams,
BendDirection, BendTableFormat, BendTableParams, BendTableResult, ConvertParams, FlangeParams, FlatBend, FlatBore, FlatCircle, FlatPatternParams,
FlatPatternResult, FlatPerforation, FlatPerforationPattern, FlatPocket, FlatSegment, FlatShape, FoldParams, HemParams, ListedRule,
ManageRulesParams, ManageRulesResult, PathFlangeParams, PerforateParams, PocketSide, RuleFeatureError, RuleReply, RulesParams, RulesResult,
UnfoldParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
@@ -32,6 +34,10 @@ const CONVERT_CONTRACT: TypedContract<ConvertParams> = TypedContract::new();
const RULES_CONTRACT: TypedContract<RulesParams> = TypedContract::new();
const MANAGE_RULES_CONTRACT: TypedContract<ManageRulesParams> = TypedContract::new();
const PATH_FLANGE_CONTRACT: TypedContract<PathFlangeParams> = TypedContract::new();
const FLAT_PATTERN_CONTRACT: TypedContract<FlatPatternParams> = TypedContract::new();
const BEND_TABLE_CONTRACT: TypedContract<BendTableParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sheet.flange", "Flange", crate::checked!("sheet.flange", flange))
.at("SHEET METAL", "CREATE")
@@ -53,7 +59,12 @@ pub static COMMANDS: &[CommandSpec] = &[
Thickness, K-factor and bend radius come from the rule (sheet.manage_rules); passing them here is refused, \
and so is the corner relief on a base or contour flange",
),
CommandSpec::new("sheet.path_flange", "Path Flange", path_flange).at("SHEET METAL", "CREATE").icon("path_flange").params(
CommandSpec::new("sheet.path_flange", "Path Flange", crate::checked!("sheet.path_flange", path_flange))
.at("SHEET METAL", "CREATE")
.icon("path_flange")
.contract(&PATH_FLANGE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params(
"edge: [x,y,z] on a top or bottom edge of the sheet; sketch + curves: an open chain of sketch lines drawn on a plane across \
that edge, from the edge out (each line one flange as long as the line, to the outer face from the virtual sharp; each \
corner one bend, turning the way the lines turn); radius? (default: rule); body?. One feature, one body",
@@ -121,10 +132,12 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", crate::checked!("sheet.rules", list_rules))
.contract(&RULES_CONTRACT).output(output_schema_for::<RulesResult>).icon("sheet_rules").noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", crate::checked!("sheet.flat_pattern", flat_pattern))
.at("SHEET METAL", "CREATE")
.icon("flat")
.noundo()
.contract(&FLAT_PATTERN_CONTRACT)
.output(output_schema_for::<FlatPatternResult>)
.params(
"body? → flat size, outline loops and cut-outs (flat coordinates, as point lists), cutout_shapes (the same cut-outs exactly, in the same order: {circle: {center, radius}} for a round hole, else {segments: [{line: [a, b]} | {arc: {center, radius, start_deg, sweep_deg}} | {polyline: [...]}]}), pockets (with their own cutout_shapes), bend lines and the bend table",
),
@@ -141,8 +154,10 @@ pub static COMMANDS: &[CommandSpec] = &[
→ 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).icon("bendtable")
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", crate::checked!("sheet.export_bend_table", export_bend_table)).icon("bendtable")
.noundo()
.contract(&BEND_TABLE_CONTRACT)
.output(output_schema_for::<BendTableResult>)
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; angle_deg is the bend angle and included_angle_deg the angle left between the bend's sides (180° less it); a bend that cut-outs split has a row for each piece"),
];
@@ -734,24 +749,24 @@ fn loop_circle(lp: &solidcore_record::Loop2) -> Option<(Vec2, f64)> {
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
fn loop_shape(lp: &solidcore_record::Loop2) -> Value {
fn loop_shape(lp: &solidcore_record::Loop2) -> FlatShape {
use solidcore_record::Seg2;
let pt = |v: Vec2| json!([v.x, v.y]);
let pt = |v: Vec2| [v.x, v.y];
if let Some((center, radius)) = loop_circle(lp) {
return json!({"circle": {"center": pt(center), "radius": radius}});
return FlatShape::Circle { circle: FlatCircle { center: pt(center), radius } };
}
let segments: Vec<Value> = lp
let segments: Vec<FlatSegment> = lp
.segs
.iter()
.map(|seg| match *seg {
Seg2::Line { a, b } => json!({"line": [pt(a), pt(b)]}),
Seg2::Line { a, b } => FlatSegment::Line([pt(a), pt(b)]),
Seg2::Arc { center, radius, start, sweep } => {
json!({"arc": {"center": pt(center), "radius": radius, "start_deg": start.to_degrees(), "sweep_deg": sweep.to_degrees()}})
FlatSegment::Arc { center: pt(center), radius, start_deg: start.to_degrees(), sweep_deg: sweep.to_degrees() }
}
ref other => json!({"polyline": other.polyline(1e-3).into_iter().map(pt).collect::<Vec<_>>()}),
ref other => FlatSegment::Polyline(other.polyline(1e-3).into_iter().map(pt).collect()),
})
.collect();
json!({"segments": segments})
FlatShape::Segments { segments }
}
fn flat_json(sh: &SheetBody) -> Result<Value> {
@@ -759,63 +774,98 @@ fn flat_json(sh: &SheetBody) -> Result<Value> {
let loops: Vec<Ring<Vec2>> = regions.iter().map(|r| r.outer.polyline(1e-3)).collect();
let size = loops.iter().fold(Aabb2::EMPTY, |b, l| b.union(&l.bounds())).size();
let (w, h) = (size.x, size.y);
let pt = |v: &Vec2| json!([v.x, v.y]);
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
let holes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(|h| Value::Array(h.polyline(1e-3).iter().map(pt).collect())).collect();
let hole_shapes: Vec<Value> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
let bends: Vec<Value> = sh
let pt = |v: &Vec2| [v.x, v.y];
let outline: Vec<Vec<[f64; 2]>> = loops.iter().map(|l| l.iter().map(pt).collect()).collect();
let holes: Vec<Vec<[f64; 2]>> = regions.iter().flat_map(|r| &r.holes).map(|h| h.polyline(1e-3).iter().map(pt).collect()).collect();
let hole_shapes: Vec<FlatShape> = regions.iter().flat_map(|r| &r.holes).map(loop_shape).collect();
let bends: Vec<FlatBend> = sh
.bends()
.iter()
.map(|b| {
json!({"bend": b.index, "angle_deg": b.angle_deg, "included_angle_deg": b.included_angle_deg(), "radius": b.radius, "direction": if b.up { "up" } else { "down" },
"allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)})
.map(|b| FlatBend {
bend: b.index,
angle_deg: b.angle_deg,
included_angle_deg: b.included_angle_deg(),
radius: b.radius,
direction: if b.up { BendDirection::Up } else { BendDirection::Down },
allowance: b.allowance,
length: b.length,
start: pt(&b.start),
end: pt(&b.end),
})
.collect();
let pockets: Vec<Value> = sh
let pockets: Vec<FlatPocket> = sh
.machining()?
.iter()
.map(|p| {
let side = if p.z0 == 0.0 {
"bottom"
PocketSide::Bottom
} else if p.z1 == sh.t {
"top"
PocketSide::Top
} else {
"internal"
PocketSide::Internal
};
json!({"outline": p.region.outer.polyline(1e-3).iter().map(pt).collect::<Vec<_>>(),
"cutouts": p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect::<Vec<_>>()).collect::<Vec<_>>(),
"cutout_shapes": p.region.holes.iter().map(loop_shape).collect::<Vec<_>>(),
"side": side, "depth_mm": p.z1-p.z0, "z0_mm": p.z0, "z1_mm": p.z1})
FlatPocket {
outline: p.region.outer.polyline(1e-3).iter().map(pt).collect(),
cutouts: p.region.holes.iter().map(|h| h.polyline(1e-3).iter().map(pt).collect()).collect(),
cutout_shapes: p.region.holes.iter().map(loop_shape).collect(),
side,
depth_mm: p.z1 - p.z0,
z0_mm: p.z0,
z1_mm: p.z1,
}
})
.collect();
// Holes drilled along a panel into its rim: drilled after cutting, not in the outline.
let bores: Vec<Value> = sh
let bores: Vec<FlatBore> = sh
.bores
.iter()
.map(|b| json!({"start": [b.base.x, b.base.y, b.base.z], "axis": [b.axis.x, b.axis.y, b.axis.z], "diameter": b.radius * 2.0, "length": b.height.abs()}))
.map(|b| FlatBore {
start: [b.base.x, b.base.y, b.base.z],
axis: [b.axis.x, b.axis.y, b.axis.z],
diameter: b.radius * 2.0,
length: b.height.abs(),
})
.collect();
let mut perforations = Vec::new();
for p in &sh.perforations {
let n = sh.perforation_holes(p)?.len();
perforations.push(json!({"feature": p.feature, "diameter": p.diameter, "pitch": p.pitch, "margin": p.margin,
"pattern": if p.square { "square" } else { "staggered" }, "count": n,
"open_area_mm2": n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2)}));
perforations.push(FlatPerforation {
feature: p.feature,
diameter: p.diameter,
pitch: p.pitch,
margin: p.margin,
pattern: if p.square { FlatPerforationPattern::Square } else { FlatPerforationPattern::Staggered },
count: n,
open_area_mm2: n as f64 * std::f64::consts::PI * (p.diameter / 2.0).powi(2),
});
}
let bend_zones: Vec<Value> = sh
let bend_zones: Vec<[f64; 4]> = sh
.bend_zones()
.iter()
.map(|z| {
let pts = z.outer.polyline(1e-3);
let f = |g: fn(&Vec2) -> f64, m: fn(f64, f64) -> f64, s: f64| pts.iter().map(g).fold(s, m);
json!([f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)])
[f(|p| p.x, f64::min, f64::MAX), f(|p| p.x, f64::max, f64::MIN), f(|p| p.y, f64::min, f64::MAX), f(|p| p.y, f64::max, f64::MIN)]
})
.collect();
let mut out = json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "cutout_shapes": hole_shapes, "pockets": pockets, "bends": bends, "unfolded": sh.flat,
"perforations": perforations, "bend_zones": bend_zones});
if !bores.is_empty() {
out["bores"] = Value::Array(bores);
}
Ok(out)
let out = FlatPatternResult {
body: sh.body.clone(),
rule: sh.rule.clone(),
thickness: sh.t,
k_factor: sh.k,
flat_size_mm: [w, h, sh.t],
outline,
cutouts: holes,
cutout_shapes: hole_shapes,
pockets,
bends,
unfolded: sh.flat,
perforations,
bend_zones,
bores,
param_warnings: Vec::new(),
};
serde_json::to_value(out).map_err(|e| EngineError::Other(e.to_string()))
}
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1137,16 +1187,22 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
}
/// A bend table's `format`: csv or json, in any case.
pub(crate) fn bend_table_format(t: &str) -> Option<BendTableFormat> {
match t.to_ascii_lowercase().as_str() {
"csv" => Some(BendTableFormat::Csv),
"json" => Some(BendTableFormat::Json),
_ => None,
}
}
fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sheet.export_bend_table";
let format = str_(p, "format").unwrap_or("csv").to_ascii_lowercase();
if !matches!(format.as_str(), "csv" | "json") {
return Err(bad(cmd, "`format` must be csv or json"));
}
let format = str_(p, "format").map_or(Some(BendTableFormat::Csv), bend_table_format).ok_or_else(|| bad(cmd, "`format` must be csv or json"))?;
let sh = sheet_of(s, p, cmd)?;
sh.flat_matches_body()?;
let table = flat_json(&sh)?;
let text = if format == "json" {
let text = if format == BendTableFormat::Json {
serde_json::to_string_pretty(&table).map_err(|e| EngineError::Other(e.to_string()))?
} else {
// Always quote text fields: body/rule names may include commas, quotes or newlines.
@@ -1176,11 +1232,14 @@ fn export_bend_table(s: &mut Session, p: &Value) -> Result<Value> {
}
csv
};
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
let bends = sh.bends().len();
let reply = if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "format": format, "bends": sh.bends().len()}));
}
Ok(json!({"format": format, "text": text, "bends": sh.bends().len()}))
BendTableResult::Written { path: path.to_string(), format, bends, param_warnings: Vec::new() }
} else {
BendTableResult::Text { format, text, bends, param_warnings: Vec::new() }
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
#[cfg(test)]
+204 -101
View File
@@ -8,14 +8,21 @@ use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Point
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec3};
use crate::schema::frame::SketchSolve;
use crate::schema::sketch::{
CancelResult, CoincidentMade, CoincidentParams, CoincidentResult, CollinearParams, ConcentricParams, Constrained, ConstraintResult, EditParams,
EditResult, EqualMade, EqualParams, EqualResult, ExitParams, FinishResult, FixOutcome, FixParams, FixResult, HorizontalVerticalParams,
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
};
use crate::schema::sketch_session::{
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
OptionsParams, OptionsReply, OptionsResult, SketchOptions, SolveParams, SolveResult,
};
use crate::schema::sketch_shapes::{
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, Created, DrawResult, LineParams,
PointParams, PointResult,
PointParams, PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectCorners,
RectResult, RectRoundedParams, RectSides, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
@@ -44,6 +51,16 @@ macro_rules! model_operand_doc {
};
}
const CREATE_CONTRACT: TypedContract<CreateParams> = TypedContract::new();
const CONSTRUCTION_CONTRACT: TypedContract<ConstructionParams> = TypedContract::new();
const DELETE_CONTRACT: TypedContract<DeleteParams> = TypedContract::new();
const MOVE_POINT_CONTRACT: TypedContract<MovePointParams> = TypedContract::new();
const DRAG_CONTRACT: TypedContract<DragParams> = TypedContract::new();
const DRAG_END_CONTRACT: TypedContract<DragEndParams> = TypedContract::new();
const DIMENSION_TEXT_CONTRACT: TypedContract<DimensionTextParams> = TypedContract::new();
const OPTIONS_CONTRACT: TypedContract<OptionsParams> = TypedContract::new();
const CHAIN_CONTRACT: TypedContract<ChainParams> = TypedContract::new();
const SOLVE_CONTRACT: TypedContract<SolveParams> = TypedContract::new();
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
const HORIZONTAL_VERTICAL_CONTRACT: TypedContract<HorizontalVerticalParams> = TypedContract::new();
@@ -64,11 +81,20 @@ const CIRCLE_TWO_POINT_CONTRACT: TypedContract<CircleTwoPointParams> = TypedCont
const CIRCLE_THREE_POINT_CONTRACT: TypedContract<CircleThreePointParams> = TypedContract::new();
const ARC_THREE_POINT_CONTRACT: TypedContract<ArcThreePointParams> = TypedContract::new();
const ARC_CENTER_POINT_CONTRACT: TypedContract<ArcCenterPointParams> = TypedContract::new();
const RECT_TWO_POINT_CONTRACT: TypedContract<RectTwoPointParams> = TypedContract::new();
const RECT_THREE_POINT_CONTRACT: TypedContract<RectThreePointParams> = TypedContract::new();
const RECT_ROUNDED_CONTRACT: TypedContract<RectRoundedParams> = TypedContract::new();
const RECT_CENTER_CONTRACT: TypedContract<RectCenterParams> = TypedContract::new();
const POLYGON_INSCRIBED_CONTRACT: TypedContract<PolygonInscribedParams> = TypedContract::new();
const POLYGON_CIRCUMSCRIBED_CONTRACT: TypedContract<PolygonCircumscribedParams> = TypedContract::new();
const POLYGON_EDGE_CONTRACT: TypedContract<PolygonEdgeParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
CommandSpec::new("sketch.create", "Create Sketch", crate::checked!("sketch.create", create_sketch))
.at("SOLID", "CREATE")
.icon("sketch")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<CreateResult>)
.params("plane: XY|XZ|YZ (sketch x, y along world +X, +Y on XY; +X, -Z on XZ; +Y, +Z on YZ) | construction plane name or id | {origin?: [x,y,z] numbers in mm, default [0,0,0] (not an expression), x_dir, y_dir} (in world coordinates unless frame says otherwise; a named plane is the active component's own) | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, flip?: bool | \"x\" | \"y\" (not on a face: sketch the plane from its other side, so the normal and the default extrude direction are reversed; a half turn about the plane's own x or y axis, true picks the one keeping the sketch's y axis upward; offset still moves along the unflipped normal), frame?: world (default) | component (which coordinates an explicit plane is in; the reply's `frame` says which, with a `note` when the active component is moved or turned), project_edges?: bool, activate_part?: bool (default false; on a face of a body of another component than the active one: activate that component at the picked placement first, as component.activate would, and make the sketch there; it stays active afterwards and the reply's `activated` names it), name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", crate::checked!("sketch.edit", edit_sketch))
.noundo().icon("sketch")
@@ -95,26 +121,34 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
.enabled(in_sketch)
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value, or `name = expression` (also `#name = expression`), which defines the user parameter `name` as sketch.dimension does (the reply's `defined` lists the names) (optional with as_drawn or locate), as_drawn?: bool (also dimension every other role of the shape at its size as drawn; a radius or diameter given takes the place of the other, as an edge polygon's side length does its radius, a circle otherwise takes its diameter, and a size the sketch already determines, such as the angle of a line inferred horizontal, is left out), locate?: bool (then place the shape from the sketch origin until nothing of it is left free: horizontal and vertical distances from the origin to its points, centres first, each kept only where it removes freedom, so a shape anchored to the origin or to placed geometry gets none; a point on an axis goes onto that axis instead of a 0 distance; the reply's located lists them); creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", crate::checked!("sketch.rectangle.two_point", rect_two_point))
.at("SKETCH", "CREATE")
.icon("rect")
.key("R")
.enabled(in_sketch)
.contract(&RECT_TWO_POINT_CONTRACT)
.output(output_schema_for::<RectResult>)
.params(concat!("p0, p1: opposite corners [x,y] (a coordinate may be an expression over the design's parameters: the corner follows them); construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", crate::checked!("sketch.rectangle.three_point", rect_three_point))
.at("SKETCH", "CREATE")
.icon("rect3")
.enabled(in_sketch)
.contract(&RECT_THREE_POINT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("p0, p1: first edge; p2: point on the opposite edge; construction?: bool", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.rectangle.rounded", "Rounded Rectangle", rect_rounded)
CommandSpec::new("sketch.rectangle.rounded", "Rounded Rectangle", crate::checked!("sketch.rectangle.rounded", rect_rounded))
.at("SKETCH", "CREATE")
.icon("rect_rounded")
.enabled(in_sketch)
.contract(&RECT_ROUNDED_CONTRACT)
.output(output_schema_for::<RoundedRectResult>)
.params(concat!("p0, p1: opposite corners [x,y] (a coordinate may be an expression over the design's parameters: the corner follows them, as in `sketch.rectangle.two_point`); radius: expr (the corner radius, less than half the shorter side); construction?. A 2-point rectangle whose four corners are filleted alike: one radius dimension (`param`), the other arcs equal to it. Returns sides {bottom, right, top, left} (line ids) and arcs (the corner arcs: bottom-left, bottom-right, top-right, top-left)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", crate::checked!("sketch.rectangle.center", rect_center))
.at("SKETCH", "CREATE")
.icon("rect_center")
.enabled(in_sketch)
.contract(&RECT_CENTER_CONTRACT)
.output(output_schema_for::<RectResult>)
.params(concat!("center, corner: [x,y] (a coordinate may be an expression over the design's parameters: the point follows them); construction?: bool. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", crate::checked!("sketch.circle.center", circle_center))
.at("SKETCH", "CREATE")
@@ -150,20 +184,26 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&ARC_CENTER_POINT_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("center, start, end (counter-clockwise from start; clockwise?: bool for clockwise) | center, start, sweep: signed degrees; construction?: bool", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", crate::checked!("sketch.polygon.inscribed", polygon_inscribed))
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.contract(&POLYGON_INSCRIBED_CONTRACT)
.output(output_schema_for::<PolygonResult>)
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", crate::checked!("sketch.polygon.circumscribed", polygon_circumscribed))
.at("SKETCH", "CREATE")
.icon("polygon_circumscribed")
.enabled(in_sketch)
.contract(&POLYGON_CIRCUMSCRIBED_CONTRACT)
.output(output_schema_for::<PolygonResult>)
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", crate::checked!("sketch.polygon.edge", polygon_edge))
.at("SKETCH", "CREATE")
.icon("polygon_edge")
.enabled(in_sketch)
.contract(&POLYGON_EDGE_CONTRACT)
.output(output_schema_for::<PolygonResult>)
.params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
.at("SKETCH", "CREATE")
@@ -256,40 +296,71 @@ pub static COMMANDS: &[CommandSpec] = &[
.contract(&SYMMETRY_CONTRACT)
.output(output_schema_for::<SymmetryResult>)
.params(concat!("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created); or point (or a point id as line, \"origin\" too): symmetric about that point, each pair joined by a construction line it is the midpoint of", model_operand_doc!())),
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
CommandSpec::new("sketch.construction", "Normal/Construction", crate::checked!("sketch.construction", construction))
.at("SKETCH", "CREATE")
.icon("construction")
.key("X")
.enabled(in_sketch)
.contract(&CONSTRUCTION_CONTRACT)
.output(output_schema_for::<ConstructionResult>)
.params("curves: [ids], value?: bool (default: construction unless all of them already are)"),
CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete).icon("delete")
CommandSpec::new("sketch.delete", "Delete Sketch Entities", crate::checked!("sketch.delete", sketch_delete)).icon("delete")
.enabled(in_sketch)
.contract(&DELETE_CONTRACT)
.output(output_schema_for::<DeleteResult>)
.params("entities: [curve, point or constraint ids]"),
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.move_point", "Drag Sketch Point", crate::checked!("sketch.move_point", move_point))
.icon("sketch_move")
.enabled(in_sketch)
.contract(&MOVE_POINT_CONTRACT)
.output(output_schema_for::<MovePointResult>)
.params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.move_points", "Move Sketch Points", move_points).icon("sketch_move").enabled(in_sketch).params(
"moves: [{point: ref | near: [x,y], to: [x,y]}] (each point to its own target; `near`: the sketch point closest to it, \
refused when none lies within `tolerance` or two are as close), tolerance?: mm (default 0.1); sketch? → moved: \
[{point, to, at, reached}] (`at`: where it ended up after the one solve; `reached`: whether that is `to`)",
),
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).icon("sketch_move").enabled(in_sketch).live(drag_live).params(
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", crate::checked!("sketch.drag", drag))
.icon("sketch_move")
.enabled(in_sketch)
.live(drag_live)
.contract(&DRAG_CONTRACT)
.output(output_schema_for::<DragResult>)
.params(
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: points and the material point grabbed on a line move toward `to` within the design constraints. When exact placement is impossible, bounded feasible steps minimize cursor distance. Splines slide by to - from; a circle or arc grabbed on its edge takes the radius to `to`. `with?`: [point or curve ids] moves the rest of a selection with the entity as one translation (circles keep their radii). Long moves retain the solution branch; arcs cannot sweep through their starts. Returns `reached`: progress projected onto the requested move (0..1), `at`: the grabbed position for the next frame, and `retry`: budget-limited progress can continue in another frame. `live: true` shares a work budget across held-solve decomposition and cursor correction; ordinary recovery and the final model evaluation retain their existing limits, not a wall-clock deadline. Later features stay stale until `sketch.drag_end`; `dof` stays from before the drag",
),
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).icon("sketch_move").enabled(in_sketch).doc_only().params(
CommandSpec::new("sketch.drag_end", "End Sketch Drag", crate::checked!("sketch.drag_end", drag_end))
.icon("sketch_move")
.enabled(in_sketch)
.doc_only()
.contract(&DRAG_END_CONTRACT)
.output(output_schema_for::<DragEndResult>)
.params(
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
),
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text).icon("edit")
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", crate::checked!("sketch.dimension_text", dimension_text)).icon("edit")
.enabled(in_sketch)
.contract(&DIMENSION_TEXT_CONTRACT)
.output(output_schema_for::<DimensionTextResult>)
.params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"),
CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params(
CommandSpec::new("sketch.options", "Sketch Palette Options", crate::checked!("sketch.options", sketch_options))
.icon("sketch")
.contract(&OPTIONS_CONTRACT)
.output(output_schema_for::<OptionsResult>)
.params(
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all",
),
CommandSpec::new("sketch.chain", "Chain", chain_cmd).icon("chain")
CommandSpec::new("sketch.chain", "Chain", crate::checked!("sketch.chain", chain_cmd)).icon("chain")
.enabled(in_sketch)
.noundo()
.contract(&CHAIN_CONTRACT)
.output(output_schema_for::<ChainResult>)
.params("curve: id: the curves joined to it end to end (what a double-click selects)"),
CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd).icon("compute")
CommandSpec::new("sketch.solve", "Solve Sketch", crate::checked!("sketch.solve", solve_cmd)).icon("compute")
.enabled(in_sketch)
.noundo()
.contract(&SOLVE_CONTRACT)
.output(output_schema_for::<SolveResult>)
.params("reports the solver status and degrees of freedom"),
];
@@ -305,14 +376,31 @@ pub(super) fn edit<T>(
strict: bool,
f: impl FnOnce(&mut Sketch, &mut solidcraft_doc::Document) -> Result<T>,
) -> Result<(T, Value)> {
let (out, info) = edit_solve(s, p, cmd, strict, f)?;
Ok((out, info.and_then(|info| serde_json::to_value(info).ok()).unwrap_or(Value::Null)))
}
/// [`edit`] with the solve as its typed value (`None`: the edit did not solve the sketch again).
pub(super) fn edit_solve<T>(
s: &mut Session,
p: &Value,
cmd: &str,
strict: bool,
f: impl FnOnce(&mut Sketch, &mut solidcraft_doc::Document) -> Result<T>,
) -> Result<(T, Option<SketchSolve>)> {
let id = sketch_store::target(s, p, cmd)?;
let (out, rep) = edit_solved(s, id, strict, Analysis::Freedom, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?;
Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null)))
Ok((out, rep.as_ref().map(solve_info)))
}
/// A solve's outcome as commands return it.
pub(super) fn solve_info(rep: &SolveReport) -> SketchSolve {
SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof }
}
/// A solve's outcome as commands return it, as JSON.
pub(super) fn report_json(rep: &SolveReport) -> Value {
json!({"solved": rep.keeps_orientation(), "dof": rep.dof})
serde_json::to_value(solve_info(rep)).unwrap_or(Value::Null)
}
/// What an [`edit_solved`] step leaves for the edit's own solve.
@@ -893,13 +981,10 @@ pub(super) fn frame_note(frame: Option<&str>, placement: &solidcraft_doc::Mat) -
/// The reply's `frame` for an explicit plane, with a note when a world plane went into a moved
/// or turned component (`placement`: that component's), where it differs from the component's own.
pub(super) fn frame_reply(out: &mut Value, frame: Option<&str>, placement: &solidcraft_doc::Mat) {
let Some(o) = out.as_object_mut() else { return };
fn frame_reply(out: &mut CreateResult, frame: Option<&str>, placement: &solidcraft_doc::Mat) {
let Some(frame) = frame else { return };
o.insert("frame".into(), json!(frame));
if let Some(note) = frame_note(Some(frame), placement) {
o.insert("note".into(), json!(note));
}
out.frame = Frame::named(frame);
out.note = frame_note(Some(frame), placement).map(str::to_string);
}
/// The indices of `m`'s triangles by face, for `faces` faces (triangles of a face past that
@@ -935,6 +1020,11 @@ pub(super) fn face_contains(m: &solidcraft_geom::Mesh, tris: &[usize], p: Vec3,
}
fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
reply("sketch.create", new_sketch(s, p)?)
}
/// What `sketch.create` makes, from its parameters.
fn new_sketch(s: &mut Session, p: &Value) -> Result<CreateResult> {
let cmd = "sketch.create";
// These name the plane, so they belong inside `plane`. Next to it they were ignored and the
// sketch went to XY (or the plane given) without a word.
@@ -973,7 +1063,7 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
let made = plane_ref(s, p, cmd).and_then(|plane| create_sketch_on(s, p, plane, frame, cmd));
match made {
Ok(mut out) => {
out["activated"] = json!({"component": component, "path": path});
out.activated = Some(Activated { component, path });
Ok(out)
}
Err(error) => {
@@ -984,11 +1074,11 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The rest of `sketch.create` once the active component is settled: `flip`, the sketch, the reply's `frame`.
fn create_sketch_on(s: &mut Session, p: &Value, mut plane: PlaneRef, frame: Option<&str>, cmd: &str) -> Result<Value> {
fn create_sketch_on(s: &mut Session, p: &Value, mut plane: PlaneRef, frame: Option<&str>, cmd: &str) -> Result<CreateResult> {
if let Some(about_y) = flip_param(s, p, &plane, cmd)? {
plane = PlaneRef::Flipped { base: Box::new(plane), about_y };
}
let mut out = create_sketch_with_plane(s, p, plane, None)?;
let mut out = sketch_on_plane(s, p, plane, None)?;
frame_reply(&mut out, frame, &s.active_component_frame());
Ok(out)
}
@@ -1021,6 +1111,11 @@ pub(super) fn create_face_profile(s: &mut Session, p: &Value, body: &str, index:
}
pub(super) fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result<Value> {
reply("sketch.create", sketch_on_plane(s, p, plane, internal_body)?)
}
/// A new sketch on `plane`, as `sketch.create` replies.
fn sketch_on_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result<CreateResult> {
let before = entry_point(s);
if face::is_face(p) {
face::capture_positions(s, &plane)?;
@@ -1053,20 +1148,25 @@ pub(super) fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneR
{
link = Some(super::sketch_project::project_internal_face(s, id, body, index)?);
}
if s.project_construction
&& let Some(link) = &link
{
super::sketch_project::construct_link(s, id, link)?;
}
}
let attachment = s.doc.feature(id).and_then(|feature| match &feature.kind {
FeatureKind::Sketch { plane, .. } => Some(if face::anchor(plane).is_some() {
"exact_face"
Attachment::ExactFace
} else if solidcraft_doc::sheet::plane_sheet_face(plane).is_some() {
"sheet_face"
Attachment::SheetFace
} else if internal_body.is_some() {
"internal_legacy_face"
Attachment::InternalLegacyFace
} else {
"fixed"
Attachment::Fixed
}),
_ => None,
});
Ok(json!({"sketch": id, "name": name, "projected": link,"attachment":attachment}))
Ok(CreateResult { sketch: id, name, projected: link, attachment, frame: None, note: None, activated: None, param_warnings: Vec::new() })
}
fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1458,22 +1558,19 @@ fn drop_implied_axes(sk: &mut Sketch, cons: &mut Vec<String>) {
/// Which side and corner of an axis-aligned rectangle is which, from its lines and corner
/// points as `polygon_lines` made them (counter-clockwise from the bottom-left corner), so a
/// caller can dimension it without inspecting the sketch first.
fn rect_names(sk: &Sketch, l: &[usize], pts: &[usize]) -> Value {
fn rect_names(sk: &Sketch, l: &[usize], pts: &[usize]) -> (RectSides, RectCorners) {
let curve = |i: usize| l.get(i).and_then(|c| sk.curves.get(*c)).map(|c| c.id.clone());
let point = |i: usize| pts.get(i).and_then(|q| sk.points.get(*q)).map(|q| q.id.clone());
json!({
"sides": {"bottom": curve(0), "right": curve(1), "top": curve(2), "left": curve(3)},
"corners": {"bottom_left": point(0), "bottom_right": point(1), "top_right": point(2), "top_left": point(3)},
})
(
RectSides { bottom: curve(0), right: curve(1), top: curve(2), left: curve(3) },
RectCorners { bottom_left: point(0), bottom_right: point(1), top_right: point(2), top_left: point(3) },
)
}
/// `result` plus the rectangle's named sides and corners.
fn rect_result(sk_out: (Vec<String>, Vec<String>), names: Value, info: Value) -> Value {
let mut out = result(sk_out, info);
if let (Some(o), Value::Object(n)) = (out.as_object_mut(), names) {
o.extend(n);
}
out
/// The reply of a rectangle with named sides and corners, built from [`RectResult`].
fn rect_result(sk_out: (Vec<String>, Vec<String>), (sides, corners): (RectSides, RectCorners), info: Value) -> Result<Value> {
let reply = RectResult { curves: sk_out.0, constraints: sk_out.1, sketch: info, sides, corners, created: None, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// A rectangle corner: `[x, y]` of numbers, or with a coordinate typed as an expression (as a
@@ -1518,7 +1615,7 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
drop_implied_axes(sk, &mut cons);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
})?;
Ok(rect_result(out, names, info))
rect_result(out, names, info)
}
/// A 2-point rectangle with its corners filleted, as one undo step: the rectangle and the
@@ -1542,7 +1639,7 @@ fn rect_rounded(s: &mut Session, p: &Value) -> Result<Value> {
o.remove("radius");
}
let mut scratch = s.scratch();
let mut out = scratch.execute("sketch.rectangle.two_point", &rect)?;
let out = scratch.execute("sketch.rectangle.two_point", &rect)?;
// The fillet rounds each corner by the two sides meeting there, not by the corner point: a
// corner that lands on another curve's end is shared with it, and rounding the point would
// weld the two together, leaving the corner on three curves (#7878).
@@ -1568,23 +1665,23 @@ fn rect_rounded(s: &mut Session, p: &Value) -> Result<Value> {
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
scratch.undo.clear();
s.accept_computed(scratch);
let arcs = rounded.get("curves").cloned().unwrap_or_else(|| json!([]));
if let Some(o) = out.as_object_mut() {
// The sharp corners are gone (the lines end at the arcs now).
o.remove("corners");
if let (Some(Value::Array(curves)), Value::Array(extra)) = (o.get_mut("curves"), &arcs) {
curves.extend(extra.iter().cloned());
}
if let (Some(Value::Array(cons)), Some(Value::Array(extra))) = (o.get_mut("constraints"), rounded.get("constraints")) {
cons.extend(extra.iter().cloned());
}
o.insert("arcs".into(), arcs);
o.insert("param".into(), rounded.get("param").cloned().unwrap_or(Value::Null));
if let Some(info) = rounded.get("sketch") {
o.insert("sketch".into(), info.clone());
}
}
Ok(out)
let ids = |v: &Value, k: &str| -> Vec<String> {
v.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
};
let side = |k: &str| out["sides"].get(k).and_then(Value::as_str).map(str::to_string);
let arcs = ids(&rounded, "curves");
// The sharp corners are gone (the lines end at the arcs now): the reply names no corners.
let reply = RoundedRectResult {
curves: ids(&out, "curves").into_iter().chain(arcs.iter().cloned()).collect(),
constraints: ids(&out, "constraints").into_iter().chain(ids(&rounded, "constraints")).collect(),
sketch: rounded.get("sketch").or_else(|| out.get("sketch")).cloned().unwrap_or(Value::Null),
sides: RectSides { bottom: side("bottom"), right: side("right"), top: side("top"), left: side("left") },
arcs,
param: rounded.get("param").and_then(Value::as_str).unwrap_or_default().to_string(),
created: None,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1626,7 +1723,7 @@ fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
drop_implied_axes(sk, &mut cons);
Ok(((ids_of(sk, &l), cons), rect_names(sk, &l, &pts)))
})?;
Ok(rect_result(out, names, info))
rect_result(out, names, info)
}
fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1649,7 +1746,7 @@ fn rect_three_point(s: &mut Session, p: &Value) -> Result<Value> {
mark_construction(sk, &l, p);
Ok((ids_of(sk, &l), cons))
})?;
Ok(result(out, info))
draw_reply(out, info)
}
pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
@@ -1907,9 +2004,8 @@ fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, s
}
Ok((ids_of(sk, &l), cons))
})?;
let mut value = result(out, info);
value["support_circle"] = json!(support_circle);
Ok(value)
let reply = PolygonResult { curves: out.0, constraints: out.1, sketch: info, support_circle, created: None, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
fn sides(p: &Value, cmd: &str) -> Result<usize> {
@@ -3696,7 +3792,7 @@ fn dimension_text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.dimension_text";
let key = str_(p, "dimension").ok_or_else(|| bad(cmd, "`dimension` must be a dimension id or parameter name"))?.to_string();
let at = if bool_(p, "reset").unwrap_or(false) { None } else { Some(req_vec2(cmd, p, "at")?) };
let (id, info) = edit(s, p, cmd, false, |sk, _| {
let (id, info) = edit_solve(s, p, cmd, false, |sk, _| {
let c = sk
.constraints
.iter()
@@ -3706,7 +3802,7 @@ fn dimension_text(s: &mut Session, p: &Value) -> Result<Value> {
place_text(sk, &id, at);
Ok(id)
})?;
Ok(json!({"dimension": id, "sketch": info}))
reply(cmd, DimensionTextResult { dimension: id, sketch: info, param_warnings: Vec::new() })
}
/// Dimension a point (or a parallel line) to line `l` as a diameter: when `l` is a centerline
@@ -3852,7 +3948,7 @@ fn construction(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`curves` must list curve ids"));
}
let want = bool_(p, "value");
let (n, info) = edit(s, p, cmd, false, |sk, _| {
let (n, info) = edit_solve(s, p, cmd, false, |sk, _| {
let cs: Vec<usize> =
ids.iter().map(|id| sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))).collect::<Result<_>>()?;
// The selection switches together (a slot or polygon as a whole): to construction
@@ -3876,7 +3972,7 @@ fn construction(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok((n, to))
})?;
Ok(json!({"changed": n.0, "construction": n.1, "sketch": info}))
reply(cmd, ConstructionResult { changed: n.0, construction: n.1, sketch: info, param_warnings: Vec::new() })
}
fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
@@ -3885,7 +3981,7 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad(cmd, "`entities` must list sketch entity ids"));
}
let (n, info) = edit(s, p, cmd, false, |sk, doc| {
let (n, info) = edit_solve(s, p, cmd, false, |sk, doc| {
let mut curves = Vec::new();
let mut points = Vec::new();
let mut cons = Vec::new();
@@ -3960,7 +4056,7 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
})?;
// Dimension parameters may have been dropped; prune again with the stored sketch.
s.doc_mut().prune_model_params();
Ok(json!({"deleted": n, "sketch": info}))
reply(cmd, DeleteResult { deleted: n, sketch: info, param_warnings: Vec::new() })
}
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
@@ -3973,7 +4069,7 @@ fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
let q = point_ref(sk, p.get("point"), cmd, "point")?;
Ok(((), drag_entity(sk, Ent::Point(q), &[], to, to, false, last).1.solve_again()))
})?;
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
reply(cmd, MovePointResult { sketch: rep.as_ref().map(solve_info), param_warnings: Vec::new() })
}
/// `sketch.move_points`: several points, each dragged to its own target, in one edit (one undo
@@ -4093,7 +4189,10 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
let sk = sketch_store::read(&s.doc, id)?;
let at = d.at(sk, from, to);
let rep = rep.or_else(|| last.map(|ss| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..ss.report.clone() }));
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y], "retry": d.retry});
let out = reply(
cmd,
DragResult { sketch: rep.as_ref().map(solve_info), reached: d.reached, at: [at.x, at.y], retry: d.retry, param_warnings: Vec::new() },
)?;
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
}
@@ -4119,29 +4218,30 @@ fn drag_end(s: &mut Session, p: &Value) -> Result<Value> {
}
// A new constraint changed the document: `execute` evaluates the model after this.
if !std::sync::Arc::ptr_eq(&start, &s.doc) {
return Ok(json!({"constraint": constraint, "sketch": constraint.get("sketch")}));
let sketch = constraint.get("sketch").and_then(|solve| serde_json::from_value(solve.clone()).ok());
return reply(cmd, DragEndResult { constraint, sketch, param_warnings: Vec::new() });
}
if s.model.is_stale() {
s.refresh();
}
let report = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| report_json(&ss.report));
Ok(json!({"constraint": constraint, "sketch": report}))
let sketch = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| solve_info(&ss.report));
reply(cmd, DragEndResult { constraint, sketch, param_warnings: Vec::new() })
}
/// The Sketch Palette options of a sketch, by their palette names (true = shown / on).
fn options_json(v: &solidcraft_sketch::SketchView) -> Value {
json!({
"show_profile": !v.hide_profile,
"show_points": !v.hide_points,
"show_dimensions": !v.hide_dimensions,
"show_constraints": !v.hide_constraints,
"show_projected": !v.hide_projected,
"show_construction": !v.hide_construction,
"grid": !v.hide_grid,
"snap": !v.no_snap,
"slice": v.slice,
"sketch_3d": v.three_d,
})
fn options_of(v: &solidcraft_sketch::SketchView) -> SketchOptions {
SketchOptions {
show_profile: !v.hide_profile,
show_points: !v.hide_points,
show_dimensions: !v.hide_dimensions,
show_constraints: !v.hide_constraints,
show_projected: !v.hide_projected,
show_construction: !v.hide_construction,
grid: !v.hide_grid,
snap: !v.no_snap,
slice: v.slice,
sketch_3d: v.three_d,
}
}
fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
@@ -4166,9 +4266,10 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
let set = |k: &str| bool_(p, k);
if keys.iter().all(|k| set(k).is_none()) {
let id = sketch_store::target(s, p, cmd)?;
return Ok(json!({"options": options_json(&sketch_store::read(&s.doc, id)?.view)}));
let options = OptionsReply::Read { options: options_of(&sketch_store::read(&s.doc, id)?.view) };
return reply(cmd, OptionsResult { options, param_warnings: Vec::new() });
}
let (v, info) = edit(s, p, cmd, false, |sk, _| {
let (v, info) = edit_solve(s, p, cmd, false, |sk, _| {
let v = &mut sk.view;
let put = |k: &str, flag: &mut bool, shown_means_off: bool| {
if let Some(b) = set(k) {
@@ -4185,9 +4286,9 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
put("snap", &mut v.no_snap, true);
put("slice", &mut v.slice, false);
put("sketch_3d", &mut v.three_d, false);
Ok(options_json(v))
Ok(options_of(v))
})?;
Ok(json!({"options": v, "sketch": info}))
reply(cmd, OptionsResult { options: OptionsReply::Changed { options: v, sketch: info }, param_warnings: Vec::new() })
}
fn chain_cmd(s: &mut Session, p: &Value) -> Result<Value> {
@@ -4195,14 +4296,16 @@ fn chain_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = sketch_store::target(s, p, cmd)?;
let sk = sketch_store::read(&s.doc, id)?;
let c = curve_ref(sk, p.get("curve"), cmd, "curve")?;
Ok(json!({"curves": ids_of(sk, &sk.chain(c))}))
reply(cmd, ChainResult { curves: ids_of(sk, &sk.chain(c)), param_warnings: Vec::new() })
}
fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = sketch_store::target(s, p, "sketch.solve")?;
let cmd = "sketch.solve";
let id = sketch_store::target(s, p, cmd)?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch has not been evaluated".into()))?;
Ok(json!({"status": ss.report.status, "dof": ss.report.dof, "failing": ss.report.failing, "profiles": ss.profiles.len()}))
let (status, dof, failing, profiles) = (ss.report.status.into(), ss.report.dof, ss.report.failing.clone(), ss.profiles.len());
reply(cmd, SolveResult { status, dof, failing, profiles, param_warnings: Vec::new() })
}
#[cfg(test)]
+33 -10
View File
@@ -6,29 +6,48 @@ use std::collections::HashMap;
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
use super::CommandSpec;
use super::sketch::{edit, ids_of};
use super::sketch::{Left, edit_solved, ids_of};
use super::sketch_store;
use crate::params::{bad, num, str_, vec2};
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const INSERT_DXF_CONTRACT: TypedContract<InsertDxfParams> = TypedContract::new();
const INSERT_SVG_CONTRACT: TypedContract<InsertSvgParams> = TypedContract::new();
const EXPORT_DXF_CONTRACT: TypedContract<ExportDxfParams> = TypedContract::new();
/// A command's reply as JSON, built from the type its output schema describes so the two can't
/// drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
CommandSpec::new("sketch.insert_dxf", "Insert DXF", crate::checked!("sketch.insert_dxf", insert_dxf))
.at("SKETCH", "INSERT")
.icon("sketch_dxf")
.contract(&INSERT_DXF_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
.at("SKETCH", "INSERT")
.icon("svg")
.contract(&INSERT_SVG_CONTRACT)
.output(output_schema_for::<DrawingInserted>)
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
.icon("svg")
.noundo()
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_dxf", export_dxf))
.icon("sketch_dxf")
.noundo()
.contract(&EXPORT_DXF_CONTRACT)
.output(output_schema_for::<ExportDxfResult>)
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned); units?: \"design\" | \"in\" | \"inch\" | \"mm\" | \"cm\" | \"m\" | \"ft\" (default mm); layer?: profile layer name (default \"0\"); layers?: \"single\" | \"roles\" (default \"single\": all on `layer`; \"roles\": outer on \"OUTLINE\", holes on \"CUT\"); join?: bool (default false: R12 LINE/ARC; true: closed loops as LWPOLYLINE with arc bulges)"),
];
@@ -251,10 +270,13 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
if geom.len() > solidcraft_sketch::MAX_POINTS {
return Err(solidcraft_sketch::SketchError::TooLarge.into());
}
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
// `sketch::edit`, but keeping the solve as a typed value for the reply.
let target = sketch_store::target(s, p, cmd)?;
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
Ok((ids_of(sk, &cs), texts))
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
})?;
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
// Text becomes sketch text.
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
let mut n_text = 0;
@@ -279,7 +301,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
}
}
}
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
}
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
@@ -522,9 +544,10 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "entities": entity_count}));
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
}
Ok(json!({"dxf": text, "entities": entity_count}))
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
}
#[cfg(test)]
+31 -3
View File
@@ -40,16 +40,16 @@ const AUTO_PROJECT_CONTRACT: TypedContract<AutoProjectParams> = TypedContract::n
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.project", "Project", crate::checked!("sketch.project", project_cmd)).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).contract(&PROJECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
concat!(refs_doc!(), "; projection_link?: bool (default true); construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!()),
),
CommandSpec::new("sketch.intersect", "Intersect", crate::checked!("sketch.intersect", intersect_cmd)).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).contract(&INTERSECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true)", created_doc!()),
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true); construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!()),
),
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", crate::checked!("sketch.include_3d", include_cmd))
.at("SKETCH", "CREATE")
.icon("include")
.enabled(in_sketch)
.contract(&INCLUDE_3D_CONTRACT).output(output_schema_for::<ProjectResult>).params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
.contract(&INCLUDE_3D_CONTRACT).output(output_schema_for::<ProjectResult>).params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it; construction?: bool (default the session's preference, off: the created curves are construction geometry)", created_doc!())),
CommandSpec::new("sketch.project_to_surface", "Project To Surface", crate::checked!("sketch.project_to_surface", project_to_surface))
.at("SKETCH", "CREATE")
.icon("project_surface")
@@ -452,6 +452,26 @@ fn add_link_unless_present(
Ok(Some(link))
}
/// Make the curves of `link` construction geometry: references to draw against, left out of
/// profiles (#13142). They stay linked; a link rebuilt because its shape changed comes back as
/// normal geometry, as curves switched to construction by hand do.
fn mark_construction(sk: &mut Sketch, link: &str) {
for c in sk.link_curves(link) {
if let Some(curve) = sk.curves.get_mut(c) {
curve.construction = true;
}
}
}
/// [`mark_construction`] for `link` in sketch `id`, as the session's preference asks of the face
/// edges a new sketch projects (#13142).
pub(super) fn construct_link(s: &mut Session, id: u64, link: &str) -> Result<()> {
sketch_store::update(s, id, |_, _, sk| {
mark_construction(sk, link);
Ok(())
})
}
/// A command's reply as JSON: they build their replies from the types their output schemas
/// describe, so the two can't drift apart.
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
@@ -474,6 +494,11 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
if refs.is_empty() {
return Err(bad(cmd, "`refs` is empty"));
}
let construction = match p.get("construction") {
None => s.project_construction,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(bad(cmd, "`construction` must be a boolean")),
};
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let sources: Vec<LinkSource> = refs.iter().map(|v| source(s, v, cmd)).collect::<Result<_>>()?;
let (out, present) = sketch_store::update(s, id, |s, doc, sk| {
@@ -487,6 +512,9 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
match add_link_unless_present(s, doc, id, sk, kind, src, kind == LinkKind::Project) {
Ok(Some(l)) => {
let (curves, points) = link_parts(sk, &l);
if construction {
mark_construction(sk, &l);
}
let mut result = ProjectedLink { link: Some(l.clone()), curves, points };
if !projection_link {
result.link = None;
@@ -1763,3 +1763,43 @@ fn a_second_placements_face_and_edge_project_where_it_is_placed() {
check(&s, 15.0);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()), 15.0);
}
/// #13142: projected references can start as construction geometry, asked for per call or for
/// the whole session (a preference, kept for designs started and opened like `auto_project`).
#[test]
fn projected_references_can_start_as_construction() {
let flags = |s: &mut Session| linked_curves(&inspect(s)).iter().map(|c| c["construction"] == true).collect::<Vec<_>>();
let mut s = Session::default();
box_part(&mut s);
// Off (the default): the face loop is normal geometry and closes a profile.
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let on_face = flags(&mut s);
assert!(on_face.len() == 4 && on_face.iter().all(|c| !c), "{on_face:?}");
assert_eq!(inspect(&mut s)["profiles"].as_array().map(Vec::len), Some(1));
run(&mut s, "sketch.finish", json!({}));
// Asked for in the call, and not.
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 20]}], "construction": true}));
assert_eq!(flags(&mut s), vec![true]);
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}]}));
assert_eq!(flags(&mut s), vec![true, false]);
run(&mut s, "sketch.intersect", json!({"refs": [{"edge": [0, 0, 10]}], "construction": true}));
let e = s.execute("sketch.project", &json!({"refs": [{"edge": [20, 30, 20]}], "construction": 1}));
assert!(e.is_err(), "construction must be a boolean");
run(&mut s, "sketch.finish", json!({}));
// The session's preference: a sketch on a face starts with a construction loop and no
// profile, and it holds for the next design; `construction: false` still asks for normal.
s.project_construction = true;
run(&mut s, "file.new", json!({}));
assert!(s.project_construction, "file.new");
box_part(&mut s);
run(&mut s, "sketch.create", json!({"plane": {"face": [20, 15, 20]}}));
let on_face = flags(&mut s);
assert!(on_face.len() == 4 && on_face.iter().all(|c| *c), "{on_face:?}");
assert_eq!(inspect(&mut s)["profiles"].as_array().map(Vec::len), Some(0));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 20]}]}));
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}], "construction": false}));
assert_eq!(flags(&mut s), vec![true, false]);
}
+33 -16
View File
@@ -72,7 +72,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"solid.thread" => &[("face", Pick), ("from", Pick), ("up_to", Plane)],
"solid.emboss" => &[("face", Make)],
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
"solid.press_pull" => &[("face", Pick), ("edges", Picks), ("body", Body)],
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
// Align finds its picks where they are shown and takes both ends into the moved bodies'
// frame itself: a pick mapped into the frame of the body under it would put the two ends
@@ -227,11 +227,11 @@ impl Mapper<'_> {
let m = if self.here { self.f.sketch } else { under.as_ref().map(|(m, _)| *m).unwrap_or(self.f.active) };
self.first.get_or_insert(m);
let out = self.local_point(&m, p);
// The command sees the pick in the frame of the body under it. Remember the bodies
// whose faces meet there, so a face of that body is found and a body that only
// shares its place in the model does not make the pick ambiguous (#419).
if let Some((_, names)) = &under {
remember(out.as_ref().and_then(vec3).unwrap_or(p), names.clone());
// The command sees the pick in the frame of the body under it. Remember where it was
// picked and the bodies whose faces meet there, so a face of that body is found and a
// body that only shares its place in the model does not make the pick ambiguous (#419).
if !self.here {
remember(out.as_ref().and_then(vec3).unwrap_or(p), p, under.map(|(_, names)| names).unwrap_or_default());
}
out
}
@@ -361,9 +361,19 @@ thread_local! {
/// The outermost command is `solid.move`, whose parameters stay in the world when its bodies
/// lie in differently turned frames (`split_move`).
static OUTER_MOVE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// The picks of the running command: the point it sees and the bodies whose faces meet
/// there (the first is the body under it; more than one means the pick is ambiguous).
static PICKS: std::cell::RefCell<Vec<(Vec3, Vec<String>)>> = const { std::cell::RefCell::new(Vec::new()) };
/// The picks of the running command.
static PICKS: std::cell::RefCell<Vec<Pick>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// A pick of the running command.
struct Pick {
/// The point the command sees.
local: Vec3,
/// Where it was picked.
world: Vec3,
/// The bodies whose faces meet there: the first is the body under it, more than one means
/// the pick is ambiguous, and none that no face is under it.
bodies: Vec<String>,
}
/// How many picks of one command are remembered (a command takes few).
@@ -371,21 +381,28 @@ const MAX_PICKS: usize = 4096;
/// How many bodies may meet at one pick (an ambiguous pick names them).
const MAX_PICK_BODIES: usize = 64;
/// Remember the bodies whose faces meet at a pick, against the point the command sees.
fn remember(point: Vec3, names: Vec<String>) {
let names: Vec<String> = names.into_iter().take(MAX_PICK_BODIES).collect();
/// Remember where a pick was made and the bodies whose faces meet there, against the point the
/// command sees.
fn remember(local: Vec3, world: Vec3, names: Vec<String>) {
let bodies: Vec<String> = names.into_iter().take(MAX_PICK_BODIES).collect();
PICKS.with(|picks| {
let mut picks = picks.borrow_mut();
if picks.len() < MAX_PICKS {
picks.push((point, names));
picks.push(Pick { local, world, bodies });
}
});
}
/// The bodies whose faces meet at `point`, when the running command picked it: the first is
/// the body under the pick, and more than one means the pick is ambiguous.
/// The bodies whose faces meet at `point`, when the running command picked it on a face: the
/// first is the body under the pick, and more than one means the pick is ambiguous.
pub(crate) fn picked(point: Vec3) -> Option<Vec<String>> {
PICKS.with(|picks| picks.borrow().iter().find(|(p, _)| *p == point).map(|(_, names)| names.clone()))
PICKS.with(|picks| picks.borrow().iter().find(|p| p.local == point && !p.bodies.is_empty()).map(|p| p.bodies.clone()))
}
/// Where the running command's `nth` pick seen at `point` was picked (counting from 0: two picks
/// on parts placed apart can be the same point in their own frames).
pub(crate) fn picked_world(point: Vec3, nth: usize) -> Option<Vec3> {
PICKS.with(|picks| picks.borrow().iter().filter(|p| p.local == point).nth(nth).map(|p| p.world))
}
/// Forget the picks of the last command: a new one starts with none.
+396
View File
@@ -611,3 +611,399 @@ fn a_combine_tool_stays_where_it_is_placed() {
assert_eq!(lumps(&s), Some(2), "the boss stays where it is in the world");
assert!((volume(&mut s) - 17000.0).abs() < 1e-6, "{}", volume(&mut s));
}
/// The volume of one body in its own frame (a component's body once, however often it is placed).
fn body_volume(s: &Session, body: &str) -> f64 {
s.model.state().body(body).unwrap_or_else(|| panic!("no body {body}")).measure().unwrap().volume
}
/// The body the last feature, a fillet or chamfer, keeps.
fn blend_body(s: &Session) -> Option<String> {
match &s.doc.features.last().unwrap().kind {
solidcraft_doc::FeatureKind::Fillet { body, .. } | solidcraft_doc::FeatureKind::Chamfer { body, .. } => body.as_ref().map(|b| b.0.clone()),
k => panic!("{k:?}"),
}
}
/// The points the last feature, a fillet or chamfer, keeps.
fn blend_edges(s: &Session) -> Vec<Vec3> {
match &s.doc.features.last().unwrap().kind {
solidcraft_doc::FeatureKind::Fillet { edges, .. } | solidcraft_doc::FeatureKind::Chamfer { edges, .. } => edges.iter().map(|p| p.0).collect(),
k => panic!("{k:?}"),
}
}
fn refused(s: &mut Session, id: &str, p: Value) -> String {
let features = s.doc.features.len();
let err = s.execute(id, &p).expect_err(&format!("{id} {p}")).to_string();
assert_eq!(s.doc.features.len(), features, "{id} {p}: {err}");
err
}
/// A 1 mm chamfer of a 40 mm edge between square faces.
const CUT: f64 = 20.0;
/// A box 40 x 30 x 10.
const BLOCK: f64 = 12000.0;
/// Component Base holds "Base block" and component Cap "Cap block", each a 40 x 30 x 10 box,
/// Cap's at `cap_z` in its own frame and its placement moved by `moved`; Base is active.
fn two_parts(cap_z: f64, moved: [f64; 3]) -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Base"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Base block"}));
run(&mut s, "component.create", json!({"name": "Cap", "parent": "root"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "corner": [0, 0, cap_z], "body_name": "Cap block"}));
if moved != [0.0; 3] {
run(&mut s, "occurrence.move", json!({"occurrence": "Cap:1", "translate": moved}));
}
run(&mut s, "component.activate", json!({"component": "Base"}));
s
}
/// Component Lower holds "Lower block" (40 x 30 x 10) and component Upper "Upper block" (40 x 30
/// x 2), both from their frames' origins, so Upper's bottom edges lie on Lower's in their own
/// frames. Upper:1 sits on Lower, and a second placement, Upper:2, under it. The root is active.
fn lower_and_uppers() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Lower"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Lower block"}));
run(&mut s, "component.create", json!({"name": "Upper", "parent": "root"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 2, "body_name": "Upper block"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Upper:1", "translate": [0, 0, 10]}));
let under = run(&mut s, "occurrence.copy", json!({"component": "Upper"}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": under, "space": "world", "translate": [0, 0, -12]}));
let (lo, hi) = world_bbox(&s, "Upper block (Upper:2)");
assert!(near(lo, Vec3::new(0.0, 0.0, -2.0)) && near(hi, Vec3::new(40.0, 30.0, 0.0)), "{lo:?} {hi:?}");
(s, under)
}
/// A world pick on an edge of a moved part finds that part, though another part has an edge at
/// the same point in its own frame, and the blend keeps the body it found.
#[test]
fn edge_picks_on_a_moved_part_find_it_past_other_parts_frames() {
let round = (1.0 - std::f64::consts::PI / 4.0) * 40.0;
for (case, mut s, id, p, body, base, cap) in [
("v0", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
("v4", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1}), "Cap block", BLOCK, BLOCK - CUT),
("v5", two_parts(0.0, [500.0, 0.0, 0.0]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
(
"v6",
two_parts(0.0, [0.0, 0.0, 50.0]),
"solid.chamfer",
json!({"edges": [[20, 0, 0]], "distance": 1, "tangent_chain": false}),
"Base block",
BLOCK - CUT,
BLOCK,
),
("fillet", two_parts(0.0, [0.0, 0.0, 50.0]), "solid.fillet", json!({"edges": [[20, 0, 0]], "radius": 1}), "Base block", BLOCK - round, BLOCK),
(
"press_pull",
two_parts(0.0, [0.0, 0.0, 50.0]),
"solid.press_pull",
json!({"edges": [[20, 0, 0]], "distance": 1}),
"Base block",
BLOCK - round,
BLOCK,
),
("v1 unplaced", two_parts(50.0, [0.0; 3]), "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}), "Base block", BLOCK - CUT, BLOCK),
] {
if case == "v5" {
run(&mut s, "component.activate", json!({"component": "root"}));
}
let features = s.doc.features.len();
run(&mut s, id, p.clone());
assert_eq!(s.doc.features.len(), features + 1, "{case}");
assert_eq!(blend_body(&s).as_deref(), Some(body), "{case}");
let (b, c) = (body_volume(&s, "Base block"), body_volume(&s, "Cap block"));
assert!((b - base).abs() < 1e-3 * CUT && (c - cap).abs() < 1e-3 * CUT, "{case}: Base {b}, Cap {c}");
}
// The pick on Cap is kept in Cap's frame.
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1}));
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
}
/// Where the edges of two parts lie in one place in the world, a pick without `body` is refused,
/// naming both; with `body`, or a pick's own `body`, the named part is cut.
#[test]
fn edge_picks_where_two_parts_meet_name_both() {
for (case, cap_z, moved) in [("v2 unplaced", 0.0, [0.0; 3]), ("v3 moved", 50.0, [0.0, 0.0, -50.0])] {
for (id, size) in [("solid.chamfer", "distance"), ("solid.fillet", "radius")] {
let mut s = two_parts(cap_z, moved);
let err = refused(&mut s, id, json!({"edges": [[20, 0, 0]], size: 1}));
assert!(
err.contains("[20, 0, 0] lies on edges of 2 bodies, Base block and Cap block") && err.contains("with `body`"),
"{case} {id}: {err}"
);
assert!(!err.contains("hidden"), "{err}");
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
}
let mut s = two_parts(cap_z, moved);
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Cap block"}));
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK, "{case}");
let mut s = two_parts(cap_z, moved);
run(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 0], "body": "Cap block"}], "distance": 1}));
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK, "{case}");
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"), "{case}");
}
}
/// The lower block's outer bottom edge, with a hidden upper block placed under it: that block's
/// top edge lies there too, so the pick is refused and the message says it is hidden. A lower
/// block's edge whose only twin is in the upper block's own frame is cut.
#[test]
fn edge_picks_beside_a_hidden_placement_name_it() {
let (mut s, under) = lower_and_uppers();
run(&mut s, "browser.visibility", json!({"visible": false, "paths": [[under]]}));
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!(
err.contains("lies on edges of 2 bodies, Lower block and Upper block (Upper:2)") && err.contains("(Upper block (Upper:2) is hidden)"),
"{err}"
);
run(&mut s, "browser.visibility", json!({"visible": true, "paths": [[under]]}));
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!(err.contains("Lower block and Upper block (Upper:2)") && !err.contains("hidden"), "{err}");
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Lower block"}));
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
// Upper:2 out of the way: the lower block's bottom left edge has a twin only in Upper's frame.
let (mut s, under) = lower_and_uppers();
run(&mut s, "occurrence.move", json!({"occurrence": under, "space": "world", "translate": [0, 0, -100]}));
run(&mut s, "solid.chamfer", json!({"edges": [[0, 15, 0]], "distance": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
assert!((body_volume(&s, "Lower block") - (BLOCK - 15.0)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
}
/// The lower block's top edge, where the upper block on it has its bottom edges, is refused
/// naming both; `body` cuts either. A second lower block placed on the upper one meets it at the
/// upper block's top edges likewise.
#[test]
fn edge_picks_on_a_top_edge_under_another_part_name_both() {
let (mut s, _) = lower_and_uppers();
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1}));
assert!(err.contains("[20, 0, 10] lies on edges of 2 bodies, Lower block and Upper block:"), "{err}");
for (body, lower, upper) in [("Lower block", BLOCK - CUT, 2400.0), ("Upper block", BLOCK, 2400.0 - CUT)] {
let (mut s, _) = lower_and_uppers();
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1, "body": body}));
let (b, c) = (body_volume(&s, "Lower block"), body_volume(&s, "Upper block"));
assert!((b - lower).abs() < 1e-3 * CUT && (c - upper).abs() < 1e-3 * CUT, "{body}: Lower {b}, Upper {c}");
}
let (mut s, _) = lower_and_uppers();
let stacked = run(&mut s, "occurrence.copy", json!({"component": "Lower"}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": stacked, "space": "world", "translate": [0, 0, 12]}));
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 12]], "distance": 1}));
assert!(err.contains("[20, 0, 12] lies on edges of 2 bodies, Upper block and Lower block (Lower:2):"), "{err}");
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 12]], "distance": 1, "body": "Lower block (Lower:2)"}));
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
}
/// A block's top edge inside the bottom face of a plate placed on it: the block alone has an edge
/// there.
#[test]
fn an_edge_inside_another_parts_face_is_picked() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Lower"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Lower block"}));
run(&mut s, "component.create", json!({"name": "Plate", "parent": "root"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 2, "corner": [-30, -35, 0], "body_name": "Plate block"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Plate:1", "translate": [0, 0, 10]}));
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 10]], "distance": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
assert!((body_volume(&s, "Lower block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Plate block") == 20000.0);
}
/// Round edges of two parts in one place: a pick a little inside them, as a click on the drawn
/// edge lands, is refused naming both. A disc edge 0.2 mm out is another edge.
#[test]
fn coincident_round_edges_of_two_parts_are_refused() {
let parts = |b_radius: f64| {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "A"}));
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 10, "body_name": "A rod"}));
run(&mut s, "component.create", json!({"name": "B", "parent": "root"}));
run(&mut s, "solid.cylinder", json!({"radius": b_radius, "height": 2, "body_name": "B disc"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "B:1", "translate": [0, 0, 10]}));
s
};
let pi = std::f64::consts::PI;
let disc = |r: f64| pi * r * r * 2.0;
// A round of radius 1 on the rod's top edge, radius 10: its section's area at its centroid's
// radius.
let (a, c) = (1.0 - pi / 4.0, (10.0 - 3.0 * pi) / (3.0 * (4.0 - pi)));
let rounded = pi * 100.0 * 10.0 - a * 2.0 * pi * (10.0 - c);
let mut s = parts(10.0);
let err = refused(&mut s, "solid.fillet", json!({"edges": [[0, 9.98, 10]], "radius": 1}));
assert!(err.contains("lies on edges of 2 bodies, A rod and B disc"), "{err}");
for pick in [[0.0, 9.98, 10.0], [0.0, 10.0, 10.0]] {
let mut s = parts(10.2);
run(&mut s, "solid.fillet", json!({"edges": [pick], "radius": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("A rod"), "{pick:?}");
let (v, b) = (body_volume(&s, "A rod"), body_volume(&s, "B disc"));
assert!((v - rounded).abs() < 2e-4 * rounded && (b - disc(10.2)).abs() < 1e-6 * b, "{pick:?}: {v} vs {rounded}, {b}");
}
}
/// Two picks at one point of their parts' frames, on two parts, in one command: each is looked
/// up where it was picked, and as the feature keeps one body or none, it is refused.
#[test]
fn two_picks_at_one_point_of_two_parts_frames_are_refused() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0], [20, 0, 50]], "distance": 1}));
assert!(err.contains("the picks lie on 2 bodies, Base block and Cap block") && err.contains("in a command of its own"), "{err}");
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
}
/// One part placed twice: its edge picked through both placements, or through two placements in
/// one place, is one edge of one body.
#[test]
fn one_edge_of_two_placements_is_one_edge() {
for (offset, edges) in [
([0.0, 40.0, 0.0], json!([[20, 0, 0], [20, 40, 0]])),
([0.0; 3], json!([[20, 0, 0]])),
// Overlapping along the edge: the pick is at x 25 of one placement and x 15 of the other.
([10.0, 0.0, 0.0], json!([[25, 0, 0]])),
] {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Pin"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Pin block"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let copy = run(&mut s, "occurrence.copy", json!({"component": "Pin"}))["occurrence"].as_u64().unwrap();
if offset != [0.0; 3] {
run(&mut s, "occurrence.move", json!({"occurrence": copy, "space": "world", "translate": offset}));
}
run(&mut s, "solid.chamfer", json!({"edges": edges, "distance": 1}));
assert!((body_volume(&s, "Pin block") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{offset:?}: {}", body_volume(&s, "Pin block"));
assert!((volume(&mut s) - 2.0 * (BLOCK - CUT)).abs() < 2e-3 * CUT, "{offset:?}");
assert_eq!(blend_body(&s), None, "one body in the model");
}
}
/// An edge given by body and index keeps its body on the feature.
#[test]
fn an_edge_by_index_keeps_its_body() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
let tol = 1e-3_f64.max(s.model.state().body("Base block").unwrap().body.size() * 2e-3);
let edges = s.model.state().body("Base block").unwrap().body.edges(tol).unwrap();
let index = edges.iter().position(|e| e.mid.dist(Vec3::new(20.0, 0.0, 0.0)) < 1e-6).unwrap();
run(&mut s, "solid.chamfer", json!({"edges": [{"body": "Base block", "index": index}], "distance": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("Base block"));
assert!((body_volume(&s, "Base block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Cap block") == BLOCK);
}
/// A pick 0.42 mm off a moved part's edge, beyond the faces the placement is found by, is still
/// looked up where it was picked: another part's edge there in its own frame is not in the way.
#[test]
fn a_pick_beside_an_edge_is_looked_up_where_it_was_picked() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
run(&mut s, "solid.chamfer", json!({"edges": [[20, -0.3, -0.3]], "distance": 1}));
assert_eq!(blend_body(&s).as_deref(), Some("Base block"));
assert!((body_volume(&s, "Base block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Cap block") == BLOCK);
}
/// A chamfer of a moved part, found by a world pick, stays on that part through an edit of
/// the other part, a recompute, undo and redo, and Edit Feature.
#[test]
fn a_blend_found_by_a_world_pick_keeps_its_part() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 50]], "distance": 1, "name": "Edge"}));
let check = |s: &Session, base: f64, cap: f64, step: &str| {
let (b, c) = (body_volume(s, "Base block"), body_volume(s, "Cap block"));
assert!((b - base).abs() < 1e-3 * CUT && (c - cap).abs() < 1e-3 * CUT, "{step}: Base {b}, Cap {c}");
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{step}: {:?}", s.model.results);
};
check(&s, BLOCK, BLOCK - CUT, "made");
run(&mut s, "timeline.edit", json!({"feature": "Box1", "set": {"length": "50"}}));
check(&s, 15000.0, BLOCK - CUT, "Base edited");
run(&mut s, "timeline.compute_all", json!({}));
check(&s, 15000.0, BLOCK - CUT, "computed");
run(&mut s, "edit.undo", json!({}));
check(&s, BLOCK, BLOCK - CUT, "undone");
run(&mut s, "edit.redo", json!({}));
check(&s, 15000.0, BLOCK - CUT, "redone");
run(&mut s, "timeline.redefine", json!({"feature": "Edge", "command": "solid.chamfer", "params": {"edges": [[20, 0, 50]], "distance": 2}}));
check(&s, 15000.0, BLOCK - 4.0 * CUT, "redefined");
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"));
}
/// A corner of a moved part is refused at the point picked, in the world.
#[test]
fn a_corner_of_a_moved_part_is_refused_where_it_was_picked() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[0, 0, 50]], "distance": 1}));
assert!(err.contains("[0, 0, 50] is where 3 edges meet"), "{err}");
}
/// Picks far out, or overflowing a placement, in a design with moved parts are refused.
#[test]
fn hostile_edge_picks_in_a_placed_design_are_refused() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
for pick in [json!([1e300, 0, 0]), json!([1.7e308, -1.7e308, 1.7e308]), json!([[1e300, 0, 0]])] {
let err = s.execute("solid.chamfer", &json!({"edges": [pick], "distance": 1})).expect_err("refused");
assert!(matches!(err, crate::EngineError::BadParams { .. }), "{pick}: {err}");
}
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
}
/// The index of the edge of `body` whose midpoint is `mid`.
fn edge_index(s: &Session, body: &str, mid: Vec3) -> usize {
let b = s.model.state().body(body).unwrap().body.clone();
let edges = b.edges(1e-3_f64.max(b.size() * 2e-3)).unwrap();
edges.iter().position(|e| e.mid.dist(mid) < 1e-6).unwrap()
}
/// A pick's own `body` must exist and agree with the feature's `body`: the feature keeps one
/// body, and the pick is found on its own.
#[test]
fn a_picks_own_body_must_agree_with_body() {
let mut s = two_parts(0.0, [0.0, 0.0, 50.0]);
let index = edge_index(&s, "Cap block", Vec3::new(20.0, 0.0, 0.0));
for edge in [json!({"point": [20, 0, 50], "body": "Cap block"}), json!({"body": "Cap block", "index": index})] {
let err = refused(&mut s, "solid.chamfer", json!({"edges": [edge], "body": "Base block", "distance": 1}));
assert!(err.contains("an edge names `Cap block`, but `body` is `Base block`: give one"), "{edge}: {err}");
}
let err = refused(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 0], "body": "Nobody"}], "distance": 1}));
assert!(err.contains("no body `Nobody`"), "{err}");
assert_eq!((body_volume(&s, "Base block"), body_volume(&s, "Cap block")), (BLOCK, BLOCK));
run(&mut s, "solid.chamfer", json!({"edges": [{"point": [20, 0, 50], "body": "Cap block"}], "body": "Cap block", "distance": 1}));
assert!((body_volume(&s, "Cap block") - (BLOCK - CUT)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK);
}
/// Press Pull on an edge is a fillet: where two parts meet it is refused naming both, and
/// `body`, by its own name or a placement's, rounds that part.
#[test]
fn press_pull_on_an_edge_where_two_parts_meet_takes_body() {
let round = (1.0 - std::f64::consts::PI / 4.0) * 40.0;
let mut s = two_parts(50.0, [0.0, 0.0, -50.0]);
let err = refused(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!(err.contains("[20, 0, 0] lies on edges of 2 bodies, Base block and Cap block: name the one to round with `body`"), "{err}");
run(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 0]], "distance": 1, "body": "Cap block"}));
assert_eq!(blend_body(&s).as_deref(), Some("Cap block"));
assert!((body_volume(&s, "Cap block") - (BLOCK - round)).abs() < 1e-3 * CUT && body_volume(&s, "Base block") == BLOCK);
let (mut s, _) = lower_and_uppers();
let stacked = run(&mut s, "occurrence.copy", json!({"component": "Lower"}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": stacked, "space": "world", "translate": [0, 0, 12]}));
run(&mut s, "solid.press_pull", json!({"edges": [[20, 0, 12]], "distance": 1, "body": "Lower block (Lower:2)"}));
assert_eq!(blend_body(&s).as_deref(), Some("Lower block"));
assert_eq!(blend_edges(&s), vec![Vec3::new(20.0, 0.0, 0.0)]);
assert!((body_volume(&s, "Lower block") - (BLOCK - round)).abs() < 1e-3 * CUT && body_volume(&s, "Upper block") == 2400.0);
}
/// A body offset squashed flat, as only a hostile file has, leaves no placement to look a pick
/// up through: the pick is looked up in the body's own frame, so one far from every edge is
/// refused and one on an edge is cut.
#[test]
fn edge_picks_with_no_placement_to_look_through_are_checked() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Box"}));
let mut flat = solidcraft_doc::IDENTITY;
flat[2][2] = 0.0;
s.doc_mut().body_offsets.insert("Box".into(), flat);
let err = refused(&mut s, "solid.chamfer", json!({"edges": [[20, 15, 10]], "distance": 1}));
assert!(err.contains("no edge at [20, 15, 10]"), "{err}");
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert!((body_volume(&s, "Box") - (BLOCK - CUT)).abs() < 1e-3 * CUT, "{}", body_volume(&s, "Box"));
}
+24 -10
View File
@@ -519,6 +519,10 @@ pub struct Session {
pub(crate) followed: Option<cmd::inspect::Followed>,
/// Project a face's edges into a sketch created on it (the default; `sketch.auto_project`).
pub auto_project: bool,
/// Project, Intersect, Include 3D Geometry and the face edges projected into a new sketch
/// make construction geometry rather than normal geometry (off by default; #13142). A
/// preference, kept like `auto_project` when another design is opened or started.
pub project_construction: bool,
/// How calls that break a command's parameter contract are treated (off by default). It
/// belongs to the session, not the design, so it survives opening and starting designs.
pub param_check: schema::ParamCheck,
@@ -827,6 +831,7 @@ impl Session {
document_id: cmd::inspect::new_document_id(),
followed: None,
auto_project: true,
project_construction: false,
param_check: schema::ParamCheck::Off,
easyprint_upload: None,
live_links: None,
@@ -890,6 +895,7 @@ impl Session {
self.stop_computing();
}
let auto_project = self.auto_project;
let project_construction = self.project_construction;
let validation = self.validation();
let single_surface_analysis = self.single_surface_analysis;
let param_check = self.param_check;
@@ -907,6 +913,7 @@ impl Session {
self.crash_journal = crash_journal;
self.command_check = command_check;
self.auto_project = auto_project;
self.project_construction = project_construction;
// The user's, like `auto_project`: the commands that open a design evaluate it under it
// already (`Session::opened_validating`).
self.set_validation(validation);
@@ -1945,6 +1952,7 @@ impl Session {
document_id: self.document_id.clone(),
followed: None,
auto_project: self.auto_project,
project_construction: self.project_construction,
param_check: self.param_check,
easyprint_upload: self.easyprint_upload.clone(),
live_links: None,
@@ -2321,16 +2329,7 @@ impl Session {
}
fn run_script_with(&mut self, script: &Value, profile: Option<bool>, each: &mut dyn FnMut(usize, &Value)) -> Result<(Vec<Value>, Option<Value>)> {
let list = match script {
Value::Array(a) => a.clone(),
Value::Object(o) => {
o.get("commands").and_then(Value::as_array).cloned().ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
}
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
};
if list.len() > 100_000 {
return Err(EngineError::Other("script too long".into()));
}
let list = script_steps(script)?;
let mut out = Vec::new();
let mut steps = Vec::new();
let clock = timing::Clock::start();
@@ -2358,6 +2357,21 @@ impl Session {
}
}
/// A script's steps: the JSON array itself, or the `commands` array of an object; at most 100 000.
pub fn script_steps(script: &Value) -> Result<&Vec<Value>> {
let list = match script {
Value::Array(a) => a,
Value::Object(o) => {
o.get("commands").and_then(Value::as_array).ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))?
}
_ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())),
};
if list.len() > 100_000 {
return Err(EngineError::Other("script too long".into()));
}
Ok(list)
}
#[cfg(test)]
mod boolean_regression_tests;
#[cfg(test)]
+26 -1
View File
@@ -297,8 +297,11 @@ pub const IDS: &[&str] = &[
"panel.dismiss",
"panel.float",
"panel.focus",
"panel.focus.next",
"panel.focus.previous",
"panel.list",
"panel.move",
"panel.move.next_zone",
"panel.native_close",
"panel.open",
"panel.peek",
@@ -306,6 +309,12 @@ pub const IDS: &[&str] = &[
"panel.reset_pair",
"panel.resize",
"panel.resize_pair",
"panel.sash.down",
"panel.sash.left",
"panel.sash.reset",
"panel.sash.right",
"panel.sash.up",
"panel.toggle",
];
#[derive(Debug)]
@@ -314,6 +323,7 @@ pub enum PanelCommand {
Get,
Open(wire::Open),
HeaderAction(wire::HeaderAction),
Contextual { action: ContextualPanelAction, params: wire::Revision },
Select { operation: &'static str, params: wire::Select },
Move { operation: &'static str, params: wire::Move },
Resize(wire::Resize),
@@ -353,6 +363,11 @@ pub fn parse_command(id: &str, params: &Value) -> PanelResult<PanelCommand> {
}
serde_json::to_writer(Counter(0), params).map_err(|_| error(Rule::CollectionLimit, "panel command byte cap"))?;
let id = crate::find_command(id).map(|s| s.id).ok_or_else(|| error(Rule::MissingTarget, "unknown panel command"))?;
if let Some(action) = ContextualPanelAction::from_command_id(id) {
let command = PanelCommand::Contextual { action, params: read(params)? };
validate_command(&command)?;
return Ok(command);
}
let command = match id {
"panel.list" => {
let _: wire::Empty = read(params)?;
@@ -810,6 +825,11 @@ pub fn execute_command(command: PanelCommand, context: &mut PanelCommandContext<
validate_command(&command)?;
let state = context.sink.inspect();
match command {
PanelCommand::Contextual { action, params } => {
revision(params.expected_revision, &state)?;
let action = context.sink.resolve_contextual(action)?;
return value(execute_layout_action(action, context)?);
}
PanelCommand::List => return value(wire::ListReply { state, kinds: context.registry.specs().cloned().collect() }),
PanelCommand::Get => return value(wire::GetReply { state, workspaces: context.catalogue.state().summaries() }),
command @ PanelCommand::HeaderAction(_) => {
@@ -1038,6 +1058,7 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
let mut replacement = false;
let mut mutation = false;
let mut header_action = false;
let mut contextual = false;
for step in steps {
let id =
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or_else(|| error(Rule::Schema, "batch command required"))?;
@@ -1045,6 +1066,7 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
match parse_command(id, step.get("params").unwrap_or(&empty))? {
PanelCommand::List | PanelCommand::Get | PanelCommand::Save(_) => {}
PanelCommand::HeaderAction(_) => header_action = true,
PanelCommand::Contextual { .. } => contextual = true,
PanelCommand::Apply(_) | PanelCommand::Reset(_) => replacement = true,
PanelCommand::Workspace(params) => {
replacement |= params.workspace != selected;
@@ -1053,6 +1075,9 @@ pub fn preflight_batch(steps: &[Value], workspace: &WorkspaceId) -> PanelResult<
_ => mutation = true,
}
}
if contextual && steps.len() != 1 {
return Err(error(Rule::Capability, "contextual panel actions require a standalone transaction"));
}
if header_action && steps.len() != 1 {
return Err(error(Rule::Capability, "header actions require a standalone transaction"));
}
@@ -1075,7 +1100,7 @@ pub fn execute_batch(steps: &[Value], context: &mut PanelCommandContext<'_>) ->
step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or_else(|| error(Rule::Schema, "batch command required"))?;
let empty = json!({});
let command = parse_command(id, step.get("params").unwrap_or(&empty))?;
if matches!(&command, PanelCommand::HeaderAction(_)) {
if matches!(&command, PanelCommand::HeaderAction(_) | PanelCommand::Contextual { .. }) {
let result = execute_command(command, context)?;
return Ok(json!({"completed":1,"results":[result],"undone":0}));
}
+1
View File
@@ -29,6 +29,7 @@ pub fn synthetic_registry() -> PanelResult<PanelRegistry> {
surface: PanelSurface::FollowZone,
features: PanelFeatures { multi_instance: multi, ..PanelFeatures::default() },
side_height_policy: SideHeightPolicy::Content,
fixed_bottom_height: None,
header_actions: Vec::new(),
text_heavy: false,
})?;
+43 -7
View File
@@ -197,12 +197,33 @@ pub struct PanelSpec {
pub features: PanelFeatures,
#[serde(default)]
pub side_height_policy: SideHeightPolicy,
/// A rigid, expanded Bottom row. Other zones retain their ordinary bounds
/// and collapse capability. The declared height must satisfy those bounds.
#[serde(default)]
pub fixed_bottom_height: Option<f32>,
#[serde(default)]
pub header_actions: Vec<HeaderActionSpec>,
#[serde(default)]
pub text_heavy: bool,
}
impl PanelSpec {
pub fn size_bounds(&self, zone: Zone) -> (Size, Size) {
let (mut min, mut max) = (self.min_size, self.max_size);
if zone == Zone::Bottom
&& let Some(height) = self.fixed_bottom_height
{
min.height = height;
max.height = height;
}
(min, max)
}
pub fn collapsible_in(&self, zone: Zone) -> bool {
self.features.collapsible && !(zone == Zone::Bottom && self.fixed_bottom_height.is_some())
}
}
#[derive(Clone, Debug, Default)]
pub struct PanelRegistry {
specs: BTreeMap<PanelKind, PanelSpec>,
@@ -231,6 +252,11 @@ impl PanelRegistry {
if !spec.default_size.within(spec.min_size, spec.max_size) {
return Err(error(Rule::Bounds, "min <= default <= max required in both dimensions"));
}
if spec.fixed_bottom_height.is_some_and(|height| {
!height.is_finite() || height < spec.min_size.height || height > spec.max_size.height || !spec.allowed_zones.contains(&Zone::Bottom)
}) {
return Err(error(Rule::Bounds, "fixed Bottom height must satisfy registered bounds in an allowed Bottom zone"));
}
if !spec.allowed_zones.contains(&Zone::Floating) || !spec.allowed_zones.contains(&spec.default_zone) {
return Err(error(Rule::Placement, "Floating and default_zone must be allowed"));
}
@@ -875,10 +901,11 @@ pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section)
let mut max = Size { width: MAX_LOGICAL, height: MAX_LOGICAL };
for id in &section.tabs {
let spec = registry.get(&layout.instance(id)?.kind)?;
min.width = min.width.max(spec.min_size.width);
min.height = min.height.max(spec.min_size.height);
max.width = max.width.min(spec.max_size.width);
max.height = max.height.min(spec.max_size.height);
let (lower, upper) = spec.size_bounds(section.location.zone());
min.width = min.width.max(lower.width);
min.height = min.height.max(lower.height);
max.width = max.width.min(upper.width);
max.height = max.height.min(upper.height);
}
if min.width > max.width || min.height > max.height {
return Err(error(Rule::IncompatibleTabs, "tab size constraints have no intersection"));
@@ -886,6 +913,15 @@ pub fn tab_bounds(layout: &Layout, registry: &PanelRegistry, section: &Section)
Ok((min, max))
}
pub fn section_collapsible(layout: &Layout, registry: &PanelRegistry, section: &Section) -> PanelResult<bool> {
for id in &section.tabs {
if !registry.get(&layout.instance(id)?.kind)?.collapsible_in(section.location.zone()) {
return Ok(false);
}
}
Ok(true)
}
pub fn column_bounds(layout: &Layout, registry: &PanelRegistry, column: &ColumnId) -> PanelResult<(f32, f32)> {
layout.column(column)?;
let mut min: f32 = 0.0;
@@ -1024,7 +1060,7 @@ pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
if s.tabs.len() > 1 && !spec.features.tabbable {
return Err(error(Rule::FeatureDisabled, "panel cannot join tabs"));
}
if s.collapsed && !spec.features.collapsible {
if s.collapsed && !spec.collapsible_in(zone) {
return Err(error(Rule::FeatureDisabled, "panel cannot collapse"));
}
if zone == Zone::Window && !spec.features.popout {
@@ -1055,9 +1091,9 @@ pub fn validate(layout: &Layout, registry: &PanelRegistry) -> PanelResult<()> {
for id in &s.tabs {
let spec = registry.get(&layout.instance(id)?.kind)?;
if !spec.allowed_zones.contains(&return_to.zone)
|| !return_to.size.within(spec.min_size, spec.max_size)
|| !return_to.size.within(spec.size_bounds(return_to.zone).0, spec.size_bounds(return_to.zone).1)
|| return_to.column_width.is_some_and(|width| width < spec.min_size.width || width > spec.max_size.width)
|| (return_to.collapsed && !spec.features.collapsible)
|| (return_to.collapsed && !spec.collapsible_in(return_to.zone))
{
return Err(error(Rule::Placement, "native return violates member constraints"));
}
+110 -9
View File
@@ -180,8 +180,55 @@ pub struct LayoutReply {
pub availability: AvailabilityMap,
}
/// Keyboard actions whose target belongs to the live panel host, never the document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContextualPanelAction {
FocusNext,
FocusPrevious,
Toggle,
MoveNextZone,
ResizeLeft,
ResizeRight,
ResizeUp,
ResizeDown,
ResetSash,
}
impl ContextualPanelAction {
pub const ALL: [Self; 9] = [
Self::FocusNext,
Self::FocusPrevious,
Self::Toggle,
Self::MoveNextZone,
Self::ResizeLeft,
Self::ResizeRight,
Self::ResizeUp,
Self::ResizeDown,
Self::ResetSash,
];
pub const fn command_id(self) -> &'static str {
match self {
Self::FocusNext => "panel.focus.next",
Self::FocusPrevious => "panel.focus.previous",
Self::Toggle => "panel.toggle",
Self::MoveNextZone => "panel.move.next_zone",
Self::ResizeLeft => "panel.sash.left",
Self::ResizeRight => "panel.sash.right",
Self::ResizeUp => "panel.sash.up",
Self::ResizeDown => "panel.sash.down",
Self::ResetSash => "panel.sash.reset",
}
}
pub fn from_command_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|action| action.command_id() == id)
}
}
/// Same boundary for GUI and programmatic callers. No mutable Session reference.
pub trait ActionSink {
/// Optional live-host target resolution. Headless sinks cannot infer UI focus.
fn resolve_contextual(&self, _action: ContextualPanelAction) -> PanelResult<LayoutAction> {
Err(error(Rule::Availability, "live panel host keyboard context is unavailable"))
}
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply>;
fn inspect(&self) -> LayoutReply;
/// Pure projection on the authoritative service. Adapters stage bounded
@@ -931,9 +978,15 @@ impl Reservations<'_> {
}
}
fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reservations: &Reservations<'_>) -> PanelResult<()> {
fn place(
layout: &mut Layout,
id: &InstanceId,
destination: Destination,
reservations: &Reservations<'_>,
new_column_width: Option<f32>,
) -> PanelResult<()> {
match destination {
Destination::NewSection { section, index, mut location, size } => {
Destination::NewSection { section, index, mut location, mut size } => {
fresh_section(layout, id, &section)?;
if let SectionLocation::Window { return_to, .. } = &mut location {
if return_to.zone.side() && return_to.column.is_none() {
@@ -970,11 +1023,19 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
.filter(|p| p.memory.section == section && p.memory.location.zone() == location.zone())
.map(|p| p.memory.clone());
let zone = location.zone();
let mut created_column = false;
let column = if zone.side() {
if let Some(memory) = &memory
&& let Some(column) = &memory.column
{
Some(restore_column(layout, column.clone(), zone, memory.column_index, memory.column_width.unwrap_or(size.width))?)
created_column = layout.column(column).is_err();
Some(restore_column(
layout,
column.clone(),
zone,
memory.column_index,
new_column_width.unwrap_or_else(|| memory.column_width.unwrap_or(size.width)),
)?)
} else {
let existing = layout
.sections
@@ -986,14 +1047,21 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
Some(match existing {
Some(column) => column,
None => {
created_column = true;
let column = next_column_id_with_reservations(layout, reservations.columns)?;
restore_column(layout, column, zone, MAX_SECTIONS, size.width)?
restore_column(layout, column, zone, MAX_SECTIONS, new_column_width.unwrap_or(size.width))?
}
})
}
} else {
None
};
if created_column
&& new_column_width.is_some()
&& let Some(column) = &column
{
size.width = layout.column(column)?.width;
}
let index = if let Some(column) = &column { column_insert_index(layout, column, index)? } else { index };
layout.sections.insert(
index,
@@ -1019,7 +1087,7 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
s.tabs.insert(index, id.clone());
// Joining never steals active tab or resizes the group.
}
Destination::Relative { section, placement, new_section, new_column, size } => {
Destination::Relative { section, placement, new_section, new_column, mut size } => {
fresh_section(layout, id, &new_section)?;
size.validate()?;
let target = layout.section(&section)?.clone();
@@ -1039,6 +1107,9 @@ fn place(layout: &mut Layout, id: &InstanceId, destination: Destination, reserva
(target_column.clone(), target_index + usize::from(placement == RelativePlacement::Below), weight)
}
RelativePlacement::BeforeColumn | RelativePlacement::AfterColumn => {
if let Some(width) = new_column_width {
size.width = width;
}
let column = new_column.ok_or_else(|| error(Rule::Placement, "column placement requires identity"))?;
valid_id(&column.0)?;
if layout.columns.iter().any(|c| c.id == column)
@@ -1136,6 +1207,8 @@ fn apply(
mutation: Mutation,
) -> PanelResult<()> {
let MutationContext { registry, availability, caps, reservations, defer_availability } = *context;
let changes_placement =
matches!(&mutation, Mutation::Open { .. } | Mutation::Move { .. } | Mutation::Focus { .. } | Mutation::NativeClose { .. });
match mutation {
Mutation::Open { kind, instance, destination } => {
valid_id(&instance.0)?;
@@ -1156,7 +1229,7 @@ fn apply(
let mut destination = destination.unwrap_or_else(|| reopen_destination(layout, &p));
inherit_native_preference(&mut destination, p.memory.location.zone(), p.memory.preferred_extent, p.memory.extent_priority)?;
let created = !matches!(&destination, Destination::JoinTab { .. });
place(layout, &instance, destination, reservations)?;
place(layout, &instance, destination, reservations, None)?;
if created {
let section = layout.containing(&instance)?.id.clone();
let section = section_mut(layout, &section)?;
@@ -1175,7 +1248,7 @@ fn apply(
}
} else {
let destination = destination.ok_or_else(|| error(Rule::Placement, "new instance requires explicit destination"))?;
place(layout, &instance, destination, reservations)?;
place(layout, &instance, destination, reservations, None)?;
let s = layout.containing(&instance)?.clone();
layout.instances.push(PanelInstance {
id: instance.clone(),
@@ -1224,6 +1297,10 @@ fn apply(
}
let collapsed = layout.containing(&instance)?.collapsed;
let source = layout.containing(&instance)?.clone();
let column_width = match &source.column {
Some(column) => layout.column(column)?.width,
None => registry.get(&layout.instance(&instance)?.kind)?.default_size.width,
};
if let Destination::NewSection { location: SectionLocation::Window { return_to, .. }, .. } = &mut destination
&& source.location.zone() == return_to.zone
&& source.location.zone().docked()
@@ -1257,7 +1334,7 @@ fn apply(
Destination::Relative { new_section, .. } => Some(new_section.clone()),
_ => None,
};
place(layout, &instance, destination, reservations)?;
place(layout, &instance, destination, reservations, Some(column_width))?;
if let Some(id) = new_section {
let section = section_mut(layout, &id)?;
section.collapsed = collapsed;
@@ -1276,7 +1353,7 @@ fn apply(
if !p.requested_open {
let mut destination = reopen_destination(layout, &p);
inherit_native_preference(&mut destination, p.memory.location.zone(), p.memory.preferred_extent, p.memory.extent_priority)?;
place(layout, &instance, destination, reservations)?;
place(layout, &instance, destination, reservations, None)?;
if let Some(entry) = layout.instances.iter_mut().find(|p| p.id == instance) {
entry.requested_open = true;
}
@@ -1535,6 +1612,7 @@ fn apply(
let index = join.index.min(target.tabs.len());
drop(target.tabs.splice(index..index, section.tabs));
let members = target.tabs.clone();
normalize_fixed_bottom_rows(layout, registry)?;
for instance in members {
remember(layout, &instance)?;
}
@@ -1566,6 +1644,9 @@ fn apply(
layout.sections.insert(index, section);
}
}
if changes_placement {
normalize_fixed_bottom_rows(layout, registry)?;
}
// Every live member gets the same committed group geometry/proportions.
// Closed memories are intentionally independent until their panel reopens.
let members: Vec<_> = layout.sections.iter().flat_map(|s| s.tabs.iter().cloned()).collect();
@@ -1581,6 +1662,26 @@ fn apply(
Ok(())
}
fn normalize_fixed_bottom_rows(layout: &mut Layout, registry: &PanelRegistry) -> PanelResult<()> {
let mut fixed = Vec::new();
for section in layout.sections.iter().filter(|section| section.location.zone() == Zone::Bottom) {
let mut declared = false;
for instance in &section.tabs {
declared |= registry.get(&layout.instance(instance)?.kind)?.fixed_bottom_height.is_some();
}
if declared {
let (min, _) = tab_bounds(layout, registry, section)?;
fixed.push((section.id.clone(), min.height));
}
}
for (id, height) in fixed {
let section = section_mut(layout, &id)?;
section.size.height = height;
section.collapsed = false;
}
Ok(())
}
fn prune_columns(layout: &mut Layout) {
let mut used = std::collections::BTreeSet::new();
for column in layout.sections.iter().filter_map(|s| s.column.clone()).chain(layout.instances.iter().filter_map(|p| p.memory.column.clone())) {
+247 -3
View File
@@ -1,3 +1,4 @@
use super::commands::LayoutCatalogue;
use super::*;
fn id(value: &str) -> InstanceId {
@@ -22,6 +23,172 @@ fn reject(service: &mut LayoutService, mutations: Vec<Mutation>, rule: Rule) {
assert_eq!(service.inspect(), before, "failed action must preserve layout/revision/interaction");
}
#[test]
fn fixed_bottom_height_is_validated_without_weakening_other_zone_bounds() {
let base = synthetic_registry().unwrap().get(&PanelKind::new("fixture.log").unwrap()).unwrap().clone();
for height in [f32::NAN, f32::INFINITY, 0.0, 119.0, 1201.0] {
let mut spec = base.clone();
spec.fixed_bottom_height = Some(height);
assert_eq!(PanelRegistry::default().register(spec).unwrap_err().rule, Rule::Bounds);
}
let mut spec = base;
spec.fixed_bottom_height = Some(160.0);
assert_eq!(spec.size_bounds(Zone::Bottom), (size(160.0, 160.0), size(1200.0, 160.0)));
assert_eq!(spec.size_bounds(Zone::Left), (spec.min_size, spec.max_size));
assert!(!spec.collapsible_in(Zone::Bottom));
assert!(spec.collapsible_in(Zone::Left));
PanelRegistry::default().register(spec).unwrap();
}
#[test]
fn fixed_bottom_rows_normalize_placement_but_reject_collapse_and_height_commands_atomically() {
use serde_json::json;
let mut registry = synthetic_registry().unwrap();
let mut spec = registry.get(&PanelKind::new("fixture.log").unwrap()).unwrap().clone();
spec.kind = PanelKind::new("fixture.rigid-row").unwrap();
spec.fixed_bottom_height = Some(160.0);
registry.register(spec).unwrap();
let mut service =
LayoutService::new(registry.clone(), Layout::empty(WorkspaceId::new("fixture.workspace").unwrap()), HostCapabilities::default()).unwrap();
service.dispatch(transaction(vec![fixture_open("fixture.rigid-row", "row", "side", Zone::Left, 0).unwrap()])).unwrap();
service.dispatch(transaction(vec![Mutation::Collapse { section: section("side"), collapsed: true }])).unwrap();
service
.dispatch(transaction(vec![Mutation::Move {
instance: id("row"),
destination: Destination::NewSection {
section: section("bottom"),
index: 0,
location: SectionLocation::Dock { zone: Zone::Bottom },
size: size(500.0, 300.0),
},
}]))
.unwrap();
let bottom = service.layout().section(&section("bottom")).unwrap();
assert_eq!(bottom.size.height, 160.0);
assert!(!bottom.collapsed);
assert!(!section_collapsible(service.layout(), &registry, bottom).unwrap());
reject(&mut service, vec![Mutation::Collapse { section: section("bottom"), collapsed: true }], Rule::FeatureDisabled);
reject(&mut service, vec![Mutation::SetSize { section: section("bottom"), size: size(500.0, 161.0) }], Rule::Bounds);
reject(&mut service, vec![Mutation::SetBottomHeight { section: section("bottom"), height: 161.0 }], Rule::Bounds);
let mut backend = commands::InMemoryPanelBackend::new(service, fixture_command_catalogue(&registry).unwrap());
for (command, params, rule) in [
("panel.collapse", json!({"target":command_target("row"),"collapsed":true}), Rule::FeatureDisabled),
("panel.resize", json!({"target":command_target("row"),"size":{"width":500,"height":161}}), Rule::Bounds),
("panel.resize", json!({"target":command_target("row"),"size":{"width":1,"height":161},"thickness_only":true}), Rule::Bounds),
] {
let before = backend.service.inspect();
let catalogue = backend.catalogue.state().clone();
assert_eq!(backend.execute(command, &params).unwrap_err().rule, rule);
assert_eq!(backend.service.inspect(), before);
assert_eq!(backend.catalogue.state(), &catalogue);
}
backend.service.dispatch(transaction(vec![Mutation::Close { instance: id("row") }])).unwrap();
backend
.service
.dispatch(transaction(vec![Mutation::Open { kind: PanelKind::new("fixture.rigid-row").unwrap(), instance: id("row"), destination: None }]))
.unwrap();
assert_eq!(backend.service.layout().containing(&id("row")).unwrap().size.height, 160.0);
backend
.service
.dispatch(transaction(vec![Mutation::Move {
instance: id("row"),
destination: Destination::NewSection {
section: section("right"),
index: 0,
location: SectionLocation::Dock { zone: Zone::Right },
size: size(500.0, 300.0),
},
}]))
.unwrap();
backend.service.dispatch(transaction(vec![Mutation::Collapse { section: section("right"), collapsed: true }])).unwrap();
assert_eq!(backend.service.layout().section(&section("right")).unwrap().size.height, 300.0);
backend.service.dispatch(transaction(vec![fixture_open("fixture.log", "ordinary", "ordinary", Zone::Bottom, 1).unwrap()])).unwrap();
backend
.service
.dispatch(transaction(vec![Mutation::Open {
kind: PanelKind::new("fixture.rigid-row").unwrap(),
instance: id("inactive"),
destination: Some(Destination::JoinTab { section: section("ordinary"), index: 1 }),
}]))
.unwrap();
let ordinary = backend.service.layout().section(&section("ordinary")).unwrap();
assert_eq!(ordinary.active, id("ordinary"));
assert_eq!(ordinary.size.height, 160.0, "inactive fixed-row metadata still constrains its group");
reject(&mut backend.service, vec![Mutation::Collapse { section: section("ordinary"), collapsed: true }], Rule::FeatureDisabled);
}
#[test]
fn new_column_moves_preserve_source_column_width_or_use_the_registered_default() {
for source_zone in [Zone::Left, Zone::Right, Zone::Bottom, Zone::Floating] {
for relative in [true, false] {
let mut service = fixture_service(false).unwrap();
let target_zone = if source_zone == Zone::Right { Zone::Left } else { Zone::Right };
service
.dispatch(transaction(vec![
fixture_open("fixture.log", "source", "source", source_zone, 0).unwrap(),
fixture_open("fixture.log", "anchor", "anchor", target_zone, 1).unwrap(),
]))
.unwrap();
let target_column = service.layout().containing(&id("anchor")).unwrap().column.clone().unwrap();
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: target_column.clone(), width: 330.0 }])).unwrap();
if source_zone.side() {
let source_column = service.layout().containing(&id("source")).unwrap().column.clone().unwrap();
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: source_column, width: 410.0 }])).unwrap();
} else {
service.dispatch(transaction(vec![Mutation::SetSize { section: section("source"), size: size(700.0, 240.0) }])).unwrap();
}
let destination = if relative {
Destination::Relative {
section: section("anchor"),
placement: RelativePlacement::BeforeColumn,
new_section: section("moved"),
new_column: Some(ColumnId::new("moved-column").unwrap()),
size: size(999.0, 240.0),
}
} else {
Destination::NewSection {
section: section("moved"),
index: 0,
location: SectionLocation::Dock { zone: target_zone },
size: size(999.0, 240.0),
}
};
service.dispatch(transaction(vec![Mutation::Move { instance: id("source"), destination }])).unwrap();
let moved = service.layout().containing(&id("source")).unwrap();
let column = service.layout().column(moved.column.as_ref().unwrap()).unwrap();
let expected = if relative { if source_zone.side() { 410.0 } else { 280.0 } } else { 330.0 };
assert_eq!(column.width, expected);
assert_eq!(moved.size.width, if relative { expected } else { 999.0 });
assert_eq!(service.layout().column(&target_column).unwrap().width, 330.0, "joining an existing column never resizes it");
}
}
let mut service = fixture_service(false).unwrap();
service.dispatch(transaction(vec![fixture_open("fixture.log", "source", "source", Zone::Floating, 0).unwrap()])).unwrap();
service.dispatch(transaction(vec![Mutation::SetSize { section: section("source"), size: size(700.0, 240.0) }])).unwrap();
service
.dispatch(transaction(vec![Mutation::Move {
instance: id("source"),
destination: Destination::NewSection {
section: section("moved"),
index: 0,
location: SectionLocation::Dock { zone: Zone::Left },
size: size(999.0, 240.0),
},
}]))
.unwrap();
let column = service.layout().containing(&id("source")).unwrap().column.clone().unwrap();
assert_eq!(service.layout().column(&column).unwrap().width, 280.0);
service.dispatch(transaction(vec![Mutation::SetColumnWidth { column: column.clone(), width: 730.0 }])).unwrap();
service.dispatch(transaction(vec![Mutation::Close { instance: id("source") }])).unwrap();
service
.dispatch(transaction(vec![Mutation::Open { kind: PanelKind::new("fixture.log").unwrap(), instance: id("source"), destination: None }]))
.unwrap();
assert_eq!(service.layout().column(&column).unwrap().width, 730.0, "reopen restores the saved column width");
}
#[test]
fn registry_constraints_and_identifiers_are_bounded() {
let base = synthetic_registry().unwrap().specs().next().unwrap().clone();
@@ -1430,7 +1597,7 @@ fn command_inputs() -> Vec<(&'static str, serde_json::Value)> {
floating["location"] = json!({"type":"floating","origin":{"x":20,"y":30}});
let mut native = floating.clone();
native["location"] = json!({"type":"window","host":"native","origin":{"x":20,"y":30},"return_to":{"zone":"left","index":0,"size":{"width":280,"height":240},"collapsed":false}});
vec![
let mut inputs = vec![
("layout.apply", json!({"name":"Fixture default"})),
("layout.get", json!({})),
("layout.reset", json!({})),
@@ -1458,7 +1625,9 @@ fn command_inputs() -> Vec<(&'static str, serde_json::Value)> {
"panel.resize_pair",
json!({"first":"s-one","second":"s-two","first_size":{"width":280,"height":260},"second_size":{"width":280,"height":220}}),
),
]
];
inputs.extend(ContextualPanelAction::ALL.into_iter().map(|action| (action.command_id(), json!({}))));
inputs
}
#[test]
@@ -1510,6 +1679,10 @@ fn panel_command_success_replies_describe_committed_state_for_every_executable_o
backend.execute("panel.collapse", &serde_json::json!({"target":command_target("one"),"collapsed":true})).unwrap();
}
let result = backend.execute(id, &input);
if ContextualPanelAction::from_command_id(id).is_some() {
assert_eq!(result.unwrap_err().rule, Rule::Availability, "headless cannot infer shared-control focus");
continue;
}
if id == "panel.action" {
assert_eq!(result.unwrap_err().rule, Rule::Capability, "headless cannot invoke an unbound controller");
continue;
@@ -2064,6 +2237,73 @@ fn command_sidecar_collisions_and_aggregate_catalogue_growth_are_rejected_atomic
assert!(rejected, "aggregate bytes reject before count cap");
}
#[test]
fn contextual_panel_commands_resolve_once_and_commit_through_the_registered_sink() {
use commands::LayoutCatalogue;
struct FocusSink {
service: LayoutService,
resolutions: std::cell::Cell<usize>,
}
impl ActionSink for FocusSink {
fn resolve_contextual(&self, action: ContextualPanelAction) -> PanelResult<LayoutAction> {
assert_eq!(action, ContextualPanelAction::FocusNext);
self.resolutions.set(self.resolutions.get() + 1);
Ok(LayoutAction::Apply {
expected_revision: Some(self.service.inspect().revision),
mutations: vec![Mutation::Focus { instance: InstanceId::new("two").unwrap() }],
})
}
fn inspect(&self) -> LayoutReply {
self.service.inspect()
}
fn project(&self, action: LayoutAction) -> PanelResult<Layout> {
self.service.project(action)
}
fn dispatch(&mut self, action: LayoutAction) -> PanelResult<LayoutReply> {
self.service.dispatch(action)
}
}
let backend = command_ready_backend();
let mut sink = FocusSink { service: backend.service, resolutions: std::cell::Cell::new(0) };
let mut catalogue = backend.catalogue;
let registry = sink.service.registry().clone();
let before = sink.inspect();
let original_catalogue = catalogue.state().clone();
let mut context = commands::PanelCommandContext { sink: &mut sink, registry: &registry, catalogue: &mut catalogue, web: false };
assert_eq!(
commands::execute("panel.focus.next", &serde_json::json!({"expected_revision":u64::MAX}), &mut context).unwrap_err().rule,
Rule::Revision
);
assert_eq!(context.sink.inspect(), before);
let result = commands::execute("panel.focus.next", &serde_json::json!({"expected_revision":before.revision}), &mut context).unwrap();
let reply: LayoutReply = serde_json::from_value(result).unwrap();
assert_eq!(reply.layout, context.sink.inspect().layout);
assert_eq!(reply.revision, context.sink.inspect().revision);
assert_eq!(reply.interaction, context.sink.inspect().interaction);
assert_eq!(reply.interaction.focused, Some(InstanceId::new("two").unwrap()));
assert_eq!(sink.resolutions.get(), 1, "revision rejection precedes live focus resolution");
assert_eq!(catalogue.state(), &original_catalogue, "focus does not change the saved arrangement");
}
#[test]
fn contextual_panel_commands_without_live_focus_refuse_without_effects_including_batches() {
use commands::LayoutCatalogue;
for action in ContextualPanelAction::ALL {
let mut backend = command_ready_backend();
let before = backend.service.inspect();
let catalogue = backend.catalogue.state().clone();
let reason = backend.execute(action.command_id(), &serde_json::json!({})).unwrap_err();
assert_eq!(reason.rule, Rule::Availability);
assert!(reason.detail.contains("live panel host keyboard context"));
let reason = backend.batch(&[serde_json::json!({"command":action.command_id()})]).unwrap_err();
assert_eq!(reason.rule, Rule::Availability);
let reason = backend.batch(&[serde_json::json!({"command":action.command_id()}), serde_json::json!({"command":"layout.get"})]).unwrap_err();
assert_eq!(reason.rule, Rule::Capability);
assert_eq!(backend.service.inspect(), before);
assert_eq!(backend.catalogue.state(), &catalogue);
}
}
#[test]
fn session_only_panel_commands_report_capability_without_touching_document_or_model() {
let mut session = crate::Session::default();
@@ -3846,7 +4086,11 @@ fn closed_panel_reopens_with_column_identity_width_rank_and_saved_weight() {
}]))
.unwrap();
service
.dispatch(transaction(vec![Mutation::Collapse { section: section("inner"), collapsed: true }, Mutation::Close { instance: id("two") }]))
.dispatch(transaction(vec![
Mutation::SetColumnWidth { column: column.clone(), width: 310.0 },
Mutation::Collapse { section: section("inner"), collapsed: true },
Mutation::Close { instance: id("two") },
]))
.unwrap();
let memory = service.layout().instance(&id("two")).unwrap().memory.clone();
assert_eq!(memory.column, Some(column.clone()));
+1 -1
View File
@@ -83,7 +83,7 @@ fn a_port_picked_by_index_stays_a_cut_after_a_width_change() {
fn a_lost_profile_is_an_error_not_another_pick() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10], "ids": ["a", "b", "c", "d"]}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
run(&mut s, "sketch.circle.center", json!({"center": [30, 5], "radius": 3, "id": "ring"}));
run(&mut s, "sketch.finish", json!({}));
let ring = s.model.state().sketches[0].profiles.iter().position(|p| p.outer_curves == ["ring"]).unwrap();
+828
View File
@@ -1130,6 +1130,834 @@ pub struct UnlockResult {
pub param_warnings: Vec<String>,
}
// ---------------------------------------------------------------------------------------------
// The occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and
// Paste New (#13186).
//
// As above, each key reads as its handler reads it and fails with the handler's message; a key
// the handler refuses when it is left out is optional in the type and required in the schema.
// Some handlers read a key only in some cases (`occurrence.copy` reads `component` only without
// `occurrence`, `occurrence.move` reads `tolerance` only with `matrix`, and so on): those
// parameters types are read stage by stage in the handler's order, from only the keys the handler
// reads in that case, so a call that works today gets no warning and a call that fails gets the
// handler's message.
// ---------------------------------------------------------------------------------------------
/// The message of a handler's refusal, to give whole.
fn refusal(e: crate::EngineError) -> String {
match e {
crate::EngineError::BadParams { msg, .. } => msg,
e => e.to_string(),
}
}
/// The keys of `value` that are in `keys`, as an object.
fn only(value: &Value, keys: &[&str]) -> Value {
Value::Object(value.as_object().into_iter().flatten().filter(|(k, _)| keys.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect())
}
/// `K` read from `value`: first from just the keys of each of `stages` in turn (the order the
/// handler reads them in, so the first refusal is the handler's), then from all of it.
fn staged<K: serde::de::DeserializeOwned, E: serde::de::Error>(value: &Value, stages: &[&[&str]]) -> Result<K, E> {
read_stages::<K, E>(value, stages)?;
serde_path_to_error::deserialize(value).map_err(|e| E::custom(located(&e)))
}
/// What [`staged`] reads first: just the keys of each of `stages` in turn, for the first refusal.
fn read_stages<K: serde::de::DeserializeOwned, E: serde::de::Error>(value: &Value, stages: &[&[&str]]) -> Result<(), E> {
for stage in stages {
serde_path_to_error::deserialize::<_, K>(&only(value, stage)).map_err(|e| E::custom(located(&e)))?;
}
Ok(())
}
/// The keys of `value` without `keys`.
fn without(mut value: Value, keys: &[&str]) -> Value {
if let Some(object) = value.as_object_mut() {
for key in keys {
object.remove(*key);
}
}
value
}
/// `key`'s value as `cmd::move_vector` reads it: `[x, y, z]` or `{x, y, z}` of numbers below 1e9
/// in size, with its message `key must contain three finite numbers` for anything else, `null`
/// included.
fn move_point_key<'de, D: Deserializer<'de>>(d: D, key: &str, coordinate: bool) -> Result<Option<Point3>, D::Error> {
let mut one = serde_json::Map::new();
one.insert(key.to_string(), Value::deserialize(d)?);
crate::cmd::move_vector(&Value::Object(one), key, Vec3::ZERO, coordinate, "")
.map(|v| Some(Point3(v)))
.map_err(|e| D::Error::custom(whole(refusal(e))))
}
macro_rules! move_point {
($name:ident, $key:literal, $coordinate:literal) => {
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Point3>, D::Error> {
move_point_key(d, $key, $coordinate)
}
};
}
move_point!(translate_key, "translate", true);
move_point!(axis_key, "axis", false);
move_point!(origin_key, "origin", true);
move_point!(at_key, "at", true);
move_point!(point_key, "point", true);
/// A placement path as `placing::parent_path` and `component.move_bodies` read `parent_path`,
/// `component_path` and `from_path`: up to 64 occurrence ids (none is the design itself).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlacementPath(pub Vec<u64>);
impl<'de> Deserialize<'de> for PlacementPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?
.as_array()
.filter(|a| a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.map(PlacementPath)
.ok_or_else(|| D::Error::custom("must list 0..64 occurrence ids"))
}
}
impl JsonSchema for PlacementPath {
fn schema_name() -> Cow<'static, str> {
"PlacementPath".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "An exact placement path: up to 64 occurrence ids from the root down; empty for the design itself.",
"type": "array",
"items": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
"maxItems": 64
})
}
}
/// `component.revert_position`'s `occurrences`: 1 to 10000 occurrence ids.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OccurrenceIds(pub Vec<u64>);
impl<'de> Deserialize<'de> for OccurrenceIds {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?
.as_array()
.filter(|a| !a.is_empty() && a.len() <= 10_000)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.map(OccurrenceIds)
.ok_or_else(|| D::Error::custom(whole("occurrences must list 1..10000 ids")))
}
}
impl JsonSchema for OccurrenceIds {
fn schema_name() -> Cow<'static, str> {
"OccurrenceIds".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "1 to 10000 occurrence ids.",
"type": "array",
"items": {"type": "integer", "minimum": 0, "maximum": u64::MAX},
"minItems": 1,
"maxItems": 10000
})
}
}
/// `occurrence.move`'s `angle`: a number (radians) or an angle expression, which the handler
/// evaluates against the design's parameters.
#[derive(Clone, Debug, PartialEq)]
pub struct MoveAngle(pub Value);
impl<'de> Deserialize<'de> for MoveAngle {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
v @ (Value::Number(_) | Value::String(_)) => Ok(MoveAngle(v)),
_ => Err(D::Error::custom(whole("angle must be a number or angle expression"))),
}
}
}
impl JsonSchema for MoveAngle {
fn schema_name() -> Cow<'static, str> {
"MoveAngle".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "An angle in radians (a number) or an angle expression such as \"30 deg\".", "type": ["number", "string"]})
}
}
/// Which frame `occurrence.move` reads its move in: `local` or `world`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum MoveSpace {
Local,
World,
}
fn move_space<'de, D: Deserializer<'de>>(d: D) -> Result<Option<MoveSpace>, D::Error> {
super::view_navigation::read_as(d, "space must be local or world")
}
/// `occurrence.move`'s `matrix`: 12 numbers (a row-major 3×4) or 16 (a 4×4), flat or as rows. The
/// parameters read it with `tolerance` (`matrix::rigid`), which refuses what isn't a rigid move.
#[derive(Clone, Debug, PartialEq)]
pub struct MatrixParam(pub Value);
impl<'de> Deserialize<'de> for MatrixParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d).map(MatrixParam)
}
}
impl JsonSchema for MatrixParam {
fn schema_name() -> Cow<'static, str> {
"MoveMatrix".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let row = json!({"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4});
json_schema!({
"description": "A rigid move as a row-major 3x4 (12 numbers) or 4x4 (16 numbers) matrix, flat or as 3 or 4 rows of 4.",
"anyOf": [
{"type": "array", "items": {"type": "number"}, "minItems": 12, "maxItems": 12},
{"type": "array", "items": {"type": "number"}, "minItems": 16, "maxItems": 16},
{"type": "array", "items": row, "minItems": 3, "maxItems": 4}
]
})
}
}
/// `occurrence.move`'s `tolerance`, read only with `matrix`: a number above 0 and at most 1e-3.
#[derive(Clone, Debug, PartialEq)]
pub struct MatrixTolerance(pub Value);
impl<'de> Deserialize<'de> for MatrixTolerance {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d).map(MatrixTolerance)
}
}
impl JsonSchema for MatrixTolerance {
fn schema_name() -> Cow<'static, str> {
"MatrixTolerance".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A number above 0 and at most 0.001.", "type": "number", "exclusiveMinimum": 0, "maximum": 0.001})
}
}
/// `occurrence.move`'s `base_transform`: the 4×4 matrix a move starts from, as the Browser's
/// drag passes it, in the layout of a placement's `transform` (4 columns of 4 numbers): finite,
/// affine (the fourth number of the first three columns is 0, of the last 1) and invertible.
#[derive(Clone, Debug, PartialEq)]
pub struct BaseTransform(pub solidcraft_doc::Mat);
impl<'de> Deserialize<'de> for BaseTransform {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
crate::cmd::base_transform(&Value::deserialize(d)?).map(BaseTransform).map_err(|message| D::Error::custom(whole(message)))
}
}
impl JsonSchema for BaseTransform {
fn schema_name() -> Cow<'static, str> {
"BaseTransform".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let row = json!({"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4});
json_schema!({
"description": "A 4x4 matrix as 4 columns of 4 numbers, as a placement's `transform` is given (the translation is the last column): finite, affine (the fourth number of the first three columns is 0, of the last column 1) and invertible.",
"type": "array",
"items": row,
"minItems": 4,
"maxItems": 4
})
}
}
macro_rules! wraps_keys {
($params:ident, $keys:ident, $name:literal) => {
impl JsonSchema for $params {
fn schema_name() -> Cow<'static, str> {
$name.into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
$keys::json_schema(generator)
}
}
};
}
fn occurrence_required(schema: &mut Schema) {
require(schema, "occurrence");
}
fn component_and_bodies_required(schema: &mut Schema) {
require(schema, "component");
require(schema, "bodies");
}
fn component_and_sketches_required(schema: &mut Schema) {
require(schema, "component");
require(schema, "sketches");
}
fn occurrence_or_component_required(schema: &mut Schema) {
schema.insert("anyOf".into(), json!([{"required": ["occurrence"]}, {"required": ["component"]}]));
}
fn drag_schema(schema: &mut Schema) {
schema.insert("anyOf".into(), json!([{"required": ["occurrence"]}, {"required": ["path"]}]));
require(schema, "at");
require(schema, "point");
}
fn move_schema(schema: &mut Schema) {
require(schema, "occurrence");
schema.insert("allOf".into(), json!(["translate", "axis", "angle", "origin"].map(|key| json!({"not": {"required": ["matrix", key]}}))));
}
/// `sketches` as `string_list` reads it (one name or a list, with its messages), where `null`,
/// `[]` and a missing key all get the handler's `` `sketches` must list sketches ``.
fn sketch_names<'de, D: Deserializer<'de>>(d: D) -> Result<Option<super::config::NameList>, D::Error> {
let v = Value::deserialize(d)?;
let names = if v.is_null() { Default::default() } else { super::config::NameList::deserialize(v).map_err(D::Error::custom)? };
if names.0.is_empty() { Err(D::Error::custom(whole("`sketches` must list sketches"))) } else { Ok(Some(names)) }
}
fn sketch_names_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The sketches, by id or name as text: one, or a list of 1 to 10000.",
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": params::MAX_STRING_LIST}]
})
}
/// Make every pending occurrence move the occurrence's placement. The exploded view is shown,
/// never captured: it stays shown, and the design keeps its placements.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct CapturePositionParams {}
/// What `component.capture_position` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CapturePositionResult {
/// How many pending moves became the occurrences' placements.
pub captured: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Drop pending occurrence moves, putting the occurrences back where they were captured. Not an
/// undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RevertPositionParams {
/// The occurrences to put back, by id. Default: every one with a pending move.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrenceIds")]
pub occurrences: Option<OccurrenceIds>,
}
/// What `component.revert_position` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RevertPositionResult {
/// How many pending moves were dropped.
pub reverted: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Ground a placement, or let it go (a grounded part stays where it is when joints move
/// things). Without `grounded` it toggles.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = occurrence_required)]
pub struct GroundParams {
// Optional in the type and required in the schema (`occurrence_required`).
/// The occurrence, by id or name.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrenceRef")]
pub occurrence: Option<OccurrenceRef>,
/// The occurrence's exact placement, as occurrence ids from the top of the design (1 to 64),
/// ending in `occurrence`. It is only checked.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrencePath")]
pub path: Option<OccurrencePath>,
/// `true` grounds it, `false` frees it. Default: toggle.
#[serde(default)]
pub grounded: Option<bool>,
}
/// What `occurrence.ground` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct GroundResult {
/// The occurrence, by id.
pub occurrence: u64,
/// Whether it is grounded now.
pub grounded: bool,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Place a component again (Paste as an instance): a new occurrence of the same component, which
/// shares its contents, at the selected placement's transform (pending moves included) moved by
/// `translate`. Give the `occurrence` to copy, or the `component`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = occurrence_or_component_required)]
pub struct CopyKeys {
// `occurrence` or `component` is needed: optional in the type and required in the schema
// (`occurrence_or_component_required`), so that the handler's message is what a caller sees.
/// The placement to copy, by id or name. When given, `component` is not read.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrenceRef")]
pub occurrence: Option<OccurrenceRef>,
/// The component to place again, by id or name: its first placement is copied. Read only
/// without `occurrence`.
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
/// The placement's exact path, as occurrence ids from the top of the design (1 to 64),
/// ending in the placement copied. Needed for a placement inside a component placed more
/// than once, with `in_place`.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrencePath")]
pub path: Option<OccurrencePath>,
/// The component the copy goes in, by id or name. Default: the copied placement's parent.
#[serde(default, deserialize_with = "parent", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub parent: Option<ComponentRef>,
/// Move the copy by this much, in mm (up to 1e9). Default: no move.
#[serde(default, deserialize_with = "translate_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub translate: Option<Point3>,
/// `false`: the copy has the selected transform within its parent. `true`: it sits where the
/// selected placement is in the world, and `translate` is along the world axes. Default:
/// `false`.
#[serde(default)]
pub in_place: Option<bool>,
/// With `in_place: true`: the placement of `parent` to place the copy by, as occurrence ids
/// from the top (up to 64; none is the design). A parent placed more than once puts the copy
/// in every instance. Default: the parent's only placement, else the active one. Read only
/// with `in_place: true`.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlacementPath")]
pub parent_path: Option<PlacementPath>,
}
/// `occurrence.copy`'s parameters: [`CopyKeys`], with `component` read only without `occurrence`
/// and `parent_path` only with `in_place: true`, as the handler reads them.
#[derive(Debug)]
pub struct CopyParams(pub CopyKeys);
impl<'de> Deserialize<'de> for CopyParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
if value.get("occurrence").is_some() {
value = without(value, &["component"]);
}
if value.get("in_place").and_then(Value::as_bool) != Some(true) {
value = without(value, &["parent_path"]);
}
staged(&value, &[&["occurrence", "component"], &["path"], &["parent"], &["translate"]]).map(CopyParams)
}
}
wraps_keys!(CopyParams, CopyKeys, "CopyParams");
/// What `occurrence.copy` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CopyResult {
/// The new placement, by id.
pub occurrence: u64,
/// The component it places, by id.
pub component: u64,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Paste New: an independent copy of a component, with its nested components, features, bodies,
/// materials, styles, joints and motion, placed at the source placement's transform (pending
/// moves included) moved by `translate`. Unlike `occurrence.copy`, the copy shares nothing with
/// the original. A body reassigned from outside becomes base geometry; links kept to outside
/// things and joints omitted at the boundary are listed in `notices`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = component_required)]
pub struct PasteNewKeys {
// Optional in the type and required in the schema (`component_required`).
/// The component to copy, by id or name (not the design).
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
/// The source placement, by id or name; it must place `component`. Default: the component's
/// first placement.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrenceRef")]
pub occurrence: Option<OccurrenceRef>,
/// The source placement's exact path, as occurrence ids from the top of the design (1 to 64),
/// ending in the placement copied.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrencePath")]
pub path: Option<OccurrencePath>,
/// The component the copy goes in, by id or name. Default: the source placement's parent. It
/// may not be the component or one inside it.
#[serde(default, deserialize_with = "parent", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub parent: Option<ComponentRef>,
/// Move the copy by this much, in mm (up to 1e9). Default: no move.
#[serde(default, deserialize_with = "translate_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub translate: Option<Point3>,
/// `false`: the copy has the source transform within its parent. `true`: it sits where the
/// source placement is in the world, and `translate` is along the world axes. Default:
/// `false`.
#[serde(default)]
pub in_place: Option<bool>,
/// With `in_place: true`: the placement of `parent` to place the copy by, as occurrence ids
/// from the top (up to 64; none is the design). Read only with `in_place: true`.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlacementPath")]
pub parent_path: Option<PlacementPath>,
}
/// `component.paste_new`'s parameters: [`PasteNewKeys`], with `parent_path` read only with
/// `in_place: true`, as the handler reads it.
#[derive(Debug)]
pub struct PasteNewParams(pub PasteNewKeys);
impl<'de> Deserialize<'de> for PasteNewParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
if value.get("in_place").and_then(Value::as_bool) != Some(true) {
value = without(value, &["parent_path"]);
}
let first: &[&str] = if value.get("occurrence").is_some() { &["occurrence", "path"] } else { &["occurrence"] };
staged(&value, &[&["component"], first, &["translate"], &["parent"], &["path"]]).map(PasteNewParams)
}
}
wraps_keys!(PasteNewParams, PasteNewKeys, "PasteNewParams");
/// What `component.paste_new` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PasteNewResult {
/// The copy of the component, by id.
pub component: u64,
/// Its placement, by id.
pub occurrence: u64,
/// The new features, by id.
pub features: Vec<u64>,
/// How many components were made: the copy and what is inside it.
pub components: usize,
/// How many bodies the copy has.
pub bodies: usize,
/// The bodies copied as frozen geometry (their features stay with the original).
pub frozen_bodies: Vec<String>,
/// What was left behind or kept pointing outside the copy: links to bodies, sketches, planes,
/// parameters and features outside it, and joints and motion across its boundary.
pub notices: Vec<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Move an occurrence: by `translate`, or turned by `angle` about `axis` through `origin`, or by
/// a rigid `matrix` instead of those. Nested occurrences' definitions stay shared. A grounded
/// occurrence refuses unless `move_grounded` is true. When joints hold it to something fixed and
/// put it back, the reply says so (`reverted`).
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = move_schema)]
pub struct MoveKeys {
// Optional in the type and required in the schema (`move_schema`).
/// The occurrence, by id or name.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrenceRef")]
pub occurrence: Option<OccurrenceRef>,
/// The occurrence's exact placement, as occurrence ids from the top of the design (1 to 64),
/// ending in `occurrence`. Needed with `space: world` for a placement inside a component
/// placed more than once.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrencePath")]
pub path: Option<OccurrencePath>,
/// The frame of the move: `local` is the parent's (the placement's own), `world` is world
/// coordinates. Default: `local`.
#[serde(default, deserialize_with = "move_space", skip_serializing_if = "Option::is_none")]
#[schemars(with = "MoveSpace")]
pub space: Option<MoveSpace>,
/// Move by this much, in mm. Default: no move. Not with `matrix`.
#[serde(default, deserialize_with = "translate_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub translate: Option<Point3>,
/// The axis to turn about, not zero when `angle` is. Default: Z. Not with `matrix`.
#[serde(default, deserialize_with = "axis_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub axis: Option<Point3>,
/// The turn about `axis`: radians, or an angle expression. Default: 0. Not with `matrix`.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "MoveAngle")]
pub angle: Option<MoveAngle>,
/// The point the turn is about, in mm. Default: the origin. Not with `matrix`.
#[serde(default, deserialize_with = "origin_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub origin: Option<Point3>,
/// A rigid move instead of `translate`, `axis`, `angle` and `origin`: a rotation and a
/// translation. One that is a rotation only up to `tolerance` is made exact; a scale, shear or
/// mirror is refused.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "MatrixParam")]
pub matrix: Option<MatrixParam>,
/// With `matrix`: the largest accepted |RᵀR − I| per entry. Default: 1e-9. Read only with
/// `matrix`.
#[serde(default)]
pub tolerance: Option<MatrixTolerance>,
/// `true` makes the move the occurrence's placement now; `false` keeps it pending until
/// `component.capture_position`. Default: `true`.
#[serde(default)]
pub capture: Option<bool>,
/// `true` moves a grounded occurrence, which stays grounded. Default: `false`, which refuses.
#[serde(default)]
pub move_grounded: Option<bool>,
/// The transform the move starts from, in place of the occurrence's current one (what a drag
/// in the Browser passes).
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "BaseTransform")]
pub base_transform: Option<BaseTransform>,
}
/// `occurrence.move`'s parameters: [`MoveKeys`], read as the handler reads them: with `matrix`,
/// `translate`, `axis`, `angle` and `origin` are refused and `tolerance` is read; without it,
/// `tolerance` is not.
#[derive(Debug)]
pub struct MoveParams(pub MoveKeys);
impl<'de> Deserialize<'de> for MoveParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
if value.get("matrix").is_some() {
read_stages::<MoveKeys, D::Error>(&value, &[&["occurrence"]])?;
if let Some(key) = ["translate", "axis", "angle", "origin"].into_iter().find(|k| value.get(*k).is_some()) {
return Err(D::Error::custom(whole(format!("give either matrix or {key}, not both"))));
}
let tolerance = crate::cmd::matrix_tolerance(&value, "").map_err(|e| D::Error::custom(whole(refusal(e))))?;
crate::cmd::rigid_matrix(value.get("matrix").unwrap_or(&Value::Null), tolerance, "").map_err(|e| D::Error::custom(whole(refusal(e))))?;
return staged(&value, &[&["space"], &["path"]]).map(MoveParams);
}
value = without(value, &["tolerance"]);
staged(&value, &[&["occurrence"], &["translate"], &["axis"], &["angle"], &["origin"], &["space"], &["path"]]).map(MoveParams)
}
}
wraps_keys!(MoveParams, MoveKeys, "MoveParams");
/// What `occurrence.move` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MoveResult {
/// The occurrence, by id.
pub occurrence: u64,
/// Its placement in its parent now, as a 4×4 matrix (its pending one with `capture: false`).
pub transform: [[f64; 4]; 4],
/// Whether the move is pending, not captured.
pub pending: bool,
/// Present, and `true`, when joints hold the occurrence to something fixed and put it back:
/// `transform` is where it is.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub reverted: bool,
/// With `reverted`: the joint that holds it and what to change instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Drag a placement by `point - at`, in world coordinates, and capture its position: the grabbed
/// point `at` goes to `point`. Any chain of joints it is in that is joined to nothing grounded
/// moves with it; other pending moves stay pending; shared definitions move in all their
/// placements. Grounded parts and parts that joints hold to something fixed are refused
/// (`joint.reach` moves those).
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = drag_schema)]
pub struct DragKeys {
// `occurrence` or `path` is needed, and `at` and `point` both: optional in the type and
// required in the schema (`drag_schema`), so that the handler's message is what a caller sees.
/// The placement, by id or name. Without it, `path` names the placement.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrenceRef")]
pub occurrence: Option<OccurrenceRef>,
/// The placement's exact path, as occurrence ids from the top of the design (1 to 64); with
/// `occurrence` it must end in it.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrencePath")]
pub path: Option<OccurrencePath>,
/// The grabbed point on the placement, in world coordinates (mm).
#[serde(default, deserialize_with = "at_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub at: Option<Point3>,
/// Where that point goes, in world coordinates (mm).
#[serde(default, deserialize_with = "point_key", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point3")]
pub point: Option<Point3>,
/// Reply `continuous`, for a drag that goes on over several calls (the viewport's). It
/// changes nothing else. Default: `false`.
#[serde(default)]
pub continuous: Option<bool>,
}
/// `occurrence.drag`'s parameters: [`DragKeys`], read as the handler reads them: `path` stands
/// for `occurrence` when it is missing, and `at` and `point` are asked for together.
#[derive(Debug)]
pub struct DragParams(pub DragKeys);
impl<'de> Deserialize<'de> for DragParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let value = Value::deserialize(d)?;
if value.is_object() {
if value.get("occurrence").is_none() && value.get("path").is_none_or(|path| OccurrencePath::deserialize(path).is_err()) {
return Err(D::Error::custom(whole("give `occurrence` or `path` (1..64 occurrence ids)")));
}
read_stages::<DragKeys, D::Error>(&value, &[&["occurrence", "path"]])?;
if value.get("at").is_none() || value.get("point").is_none() {
return Err(D::Error::custom(whole("`at` and `point` must both be given")));
}
}
staged(&value, &[&["at"], &["point"]]).map(DragParams)
}
}
wraps_keys!(DragParams, DragKeys, "DragParams");
/// What `occurrence.drag` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DragResult {
/// The placement dragged, by id.
pub occurrence: u64,
/// Its exact path.
pub path: Vec<u64>,
/// The occurrences moved: it first, then those a chain of joints carries with it.
pub moved: Vec<u64>,
/// How far they moved, `point - at` (mm).
pub translate: [f64; 3],
/// What the call gave as `continuous`.
pub continuous: bool,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Measure an occurrence where its placement puts it, in world coordinates, with what is inside
/// it unless `descendants` is false: material-aware mass and centre of mass, and volume, density
/// and inertia for closed, consistently oriented bodies, with totals and principal moments.
/// Pending moves are included. The design is not changed.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = occurrence_required)]
pub struct OccurrencePropertiesParams {
// Optional in the type and required in the schema (`occurrence_required`).
/// The occurrence, by id or name.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrenceRef")]
pub occurrence: Option<OccurrenceRef>,
/// The occurrence's exact placement, as occurrence ids from the top of the design (1 to 64),
/// ending in `occurrence`. Needed when it is nested under several instances.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "OccurrencePath")]
pub path: Option<OccurrencePath>,
/// Include the placements inside it. Default: `true`.
#[serde(default, deserialize_with = "descendants_flag", skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub descendants: Option<bool>,
/// A point in mm, also given the total inertia about it (`total.inertia_about`).
#[serde(default, deserialize_with = "about", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<Point3>")]
pub about: Option<Point3>,
}
/// What `occurrence.properties` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct OccurrencePropertiesResult {
/// The occurrence, by id.
pub occurrence: u64,
/// Its name.
pub name: Option<String>,
#[serde(flatten)]
pub properties: PhysicalProperties,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Move bodies into another component, keeping them where they are in the world. All or
/// nothing, one undo step; refused while the timeline is rolled back.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = component_and_bodies_required)]
pub struct MoveBodiesParams {
// Optional in the type and required in the schema (`component_and_bodies_required`).
/// The bodies, by name.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "DefinitionBodies")]
pub bodies: Option<DefinitionBodies>,
/// The component they go to, by id or name.
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
/// The placement of that component to place them by, as occurrence ids from the top (up to
/// 64). A component placed more than once gets them in every instance. Default: its only
/// placement, else the active one.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlacementPath")]
pub component_path: Option<PlacementPath>,
/// The placement of their component they move from, as occurrence ids from the top (up to
/// 64). Default: its only placement, else its first, where its bodies have their own names.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PlacementPath")]
pub from_path: Option<PlacementPath>,
}
/// What `component.move_bodies` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MoveBodiesResult {
/// The bodies that moved.
pub bodies: Vec<String>,
/// The component they went to, by id.
pub component: u64,
/// The Body Transfer features that moved them, by id, in the order of `bodies`.
pub features: Vec<u64>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Move sketches into another component. They keep their own coordinates (`component.move_features`
/// keeps them where they are in the world), except that a sketch on an exactly attached face
/// stays where it is on the face.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = component_and_sketches_required)]
pub struct MoveSketchesParams {
// Optional in the type and required in the schema (`component_and_sketches_required`): left
// out, `null` or empty, the handler refuses the call with its own message.
#[serde(default, deserialize_with = "sketch_names")]
#[schemars(schema_with = "sketch_names_schema")]
pub sketches: Option<super::config::NameList>,
/// The component they go to, by id or name.
#[serde(default, deserialize_with = "component", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ComponentRef")]
pub component: Option<ComponentRef>,
}
/// What `component.move_sketches` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MoveSketchesResult {
/// The sketches that moved, by feature id.
pub sketches: Vec<u64>,
/// The component they went to, by id.
pub component: u64,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "component_tests.rs"]
mod tests;
+603 -3
View File
@@ -4,9 +4,12 @@ use std::sync::Arc;
use serde_json::{Value, json};
use super::{
ActivateParams, ActivateResult, CreateParams, CreateResult, DeleteParams, DeleteResult, EditInPlaceParams, FinishEditParams, FromBodiesParams,
FromBodiesResult, ListParams, ListResult, MoveFeaturesParams, MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult,
PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams, UnlockParams, UnlockResult,
ActivateParams, ActivateResult, CapturePositionParams, CapturePositionResult, CopyParams, CopyResult, CreateParams, CreateResult, DeleteParams,
DeleteResult, DragParams, DragResult, EditInPlaceParams, FinishEditParams, FromBodiesParams, FromBodiesResult, GroundParams, GroundResult,
ListParams, ListResult, MoveBodiesParams, MoveBodiesResult, MoveFeaturesParams, MoveParams, MoveResult, MoveSketchesParams, MoveSketchesResult,
MoveSketchesToPartsParams, OccurrenceAlignParams, OccurrenceAlignResult, OccurrencePropertiesParams, OccurrencePropertiesResult, PasteNewParams,
PasteNewResult, PropertiesParams, PropertiesResult, RenameParams, RenameResult, ReparentItemsParams, RevertPositionParams, RevertPositionResult,
UnlockParams, UnlockResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
@@ -45,6 +48,19 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<RenameParams>(), &["component", "name"]);
assert_keys(input_schema_for::<DeleteParams>(), &["component"]);
assert_keys(input_schema_for::<UnlockParams>(), &["component"]);
assert_keys(input_schema_for::<CapturePositionParams>(), &[]);
assert_keys(input_schema_for::<RevertPositionParams>(), &["occurrences"]);
assert_keys(input_schema_for::<GroundParams>(), &["grounded", "occurrence", "path"]);
assert_keys(input_schema_for::<CopyParams>(), &["component", "in_place", "occurrence", "parent", "parent_path", "path", "translate"]);
assert_keys(input_schema_for::<PasteNewParams>(), &["component", "in_place", "occurrence", "parent", "parent_path", "path", "translate"]);
assert_keys(
input_schema_for::<MoveParams>(),
&["angle", "axis", "base_transform", "capture", "matrix", "move_grounded", "occurrence", "origin", "path", "space", "tolerance", "translate"],
);
assert_keys(input_schema_for::<DragParams>(), &["at", "continuous", "occurrence", "path", "point"]);
assert_keys(input_schema_for::<OccurrencePropertiesParams>(), &["about", "descendants", "occurrence", "path"]);
assert_keys(input_schema_for::<MoveBodiesParams>(), &["bodies", "component", "component_path", "from_path"]);
assert_keys(input_schema_for::<MoveSketchesParams>(), &["component", "sketches"]);
}
/// The calls these commands already refuse (the handlers' own messages), refused alike in strict.
@@ -508,3 +524,587 @@ fn sketches_moved_to_a_part_while_an_exploded_view_is_shown_save_what_they_save_
assert!(at.dist(assembled_at) < 1e-9, "{at:?} vs {assembled_at:?}");
assert!(plane.origin.dist(assembled_plane.origin) < 1e-9, "{plane:?} vs {assembled_plane:?}");
}
// ---------------------------------------------------------------------------------------------
// The occurrence commands, Capture and Revert Position, Move Bodies and Sketches to Component and
// Paste New (#13186).
// ---------------------------------------------------------------------------------------------
/// Two ungrounded parts, `A` (with a box, `ABox`, and a sketch, `S`) and an empty `B`, in the
/// design: the session at `level`, and the ids of their placements.
fn two_parts(level: ParamCheck) -> (Session, u64, u64) {
let mut s = Session { param_check: level, ..Session::default() };
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
let a = run("component.create", json!({"name": "A", "parent": 0, "grounded": false}))["occurrence"].as_u64().unwrap_or(0);
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "ABox"}));
run("sketch.create", json!({"name": "S", "plane": "XY"}));
run("sketch.rectangle.two_point", json!({"p0": [1, 1], "p1": [3, 3]}));
run("sketch.finish", json!({}));
run("component.activate", json!({"component": 0}));
let b = run("component.create", json!({"name": "B", "parent": 0, "grounded": false, "activate": false}))["occurrence"].as_u64().unwrap_or(0);
(s, a, b)
}
/// Each case fails today (the handler's message, in Off) and fails the same in strict.
fn strict_keeps(cases: impl Fn(u64, u64) -> Vec<(&'static str, Value)>) {
let (_, a, b) = two_parts(ParamCheck::Off);
for (id, p) in cases(a, b) {
let error = |level: ParamCheck| two_parts(level).0.execute(id, &p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{id} {p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
}
}
const FLAT_MATRIX: [i32; 12] = [1, 0, 0, 5, 0, 1, 0, 0, 0, 0, 1, 0];
/// What the occurrence commands already refuse (their handlers' own messages), refused alike in
/// strict, including the keys they read only in some cases.
#[test]
fn strict_keeps_todays_errors_of_the_occurrence_commands() {
strict_keeps(|a, b| {
let at = json!([0, 0, 0]);
vec![
("component.revert_position", json!({"occurrences": null})),
("component.revert_position", json!({"occurrences": "1"})),
("component.revert_position", json!({"occurrences": []})),
("component.revert_position", json!({"occurrences": [a, "x"]})),
("component.revert_position", json!({"occurrences": vec![1; 10_001]})),
("component.revert_position", json!({"occurrences": [999]})),
("occurrence.ground", json!({})),
("occurrence.ground", json!({"occurrence": null})),
("occurrence.ground", json!({"occurrence": true})),
("occurrence.ground", json!({"occurrence": "Nope"})),
("occurrence.ground", json!({"occurrence": a, "path": null})),
("occurrence.ground", json!({"occurrence": a, "path": "x"})),
("occurrence.ground", json!({"occurrence": a, "path": []})),
("occurrence.ground", json!({"occurrence": a, "path": vec![a; 65]})),
("occurrence.copy", json!({})),
("occurrence.copy", json!({"component": null})),
("occurrence.copy", json!({"component": [1]})),
("occurrence.copy", json!({"occurrence": null})),
("occurrence.copy", json!({"occurrence": true, "component": "A"})),
("occurrence.copy", json!({"occurrence": a, "path": null})),
("occurrence.copy", json!({"occurrence": a, "path": []})),
("occurrence.copy", json!({"occurrence": a, "parent": null})),
("occurrence.copy", json!({"occurrence": a, "parent": true})),
("occurrence.copy", json!({"occurrence": a, "parent": "Nope"})),
("occurrence.copy", json!({"occurrence": a, "translate": [1, 2]})),
("occurrence.copy", json!({"occurrence": a, "translate": null})),
("occurrence.copy", json!({"occurrence": a, "translate": [1e9, 0, 0]})),
("occurrence.copy", json!({"occurrence": a, "in_place": true, "parent_path": "x"})),
("occurrence.copy", json!({"occurrence": a, "in_place": true, "parent_path": vec![1; 65]})),
("component.paste_new", json!({})),
("component.paste_new", json!({"component": null})),
("component.paste_new", json!({"component": true})),
("component.paste_new", json!({"component": 0})),
("component.paste_new", json!({"component": "A", "occurrence": null})),
("component.paste_new", json!({"component": "A", "occurrence": b})),
("component.paste_new", json!({"component": "A", "occurrence": a, "path": []})),
("component.paste_new", json!({"component": "A", "translate": [1]})),
("component.paste_new", json!({"component": "A", "parent": true})),
("component.paste_new", json!({"component": "A", "parent": "A"})),
("component.paste_new", json!({"component": "A", "in_place": true, "parent_path": 5})),
("occurrence.move", json!({})),
("occurrence.move", json!({"occurrence": null})),
("occurrence.move", json!({"occurrence": [1]})),
("occurrence.move", json!({"occurrence": a, "translate": [1, 2]})),
("occurrence.move", json!({"occurrence": a, "translate": null})),
("occurrence.move", json!({"occurrence": a, "translate": [1e9, 0, 0]})),
("occurrence.move", json!({"occurrence": a, "axis": "z"})),
("occurrence.move", json!({"occurrence": a, "origin": [1, 2, "x"]})),
("occurrence.move", json!({"occurrence": a, "angle": true})),
("occurrence.move", json!({"occurrence": a, "angle": null})),
("occurrence.move", json!({"occurrence": a, "angle": 1, "axis": [0, 0, 0]})),
("occurrence.move", json!({"occurrence": a, "space": "up"})),
("occurrence.move", json!({"occurrence": a, "space": "World"})),
("occurrence.move", json!({"occurrence": a, "space": null})),
("occurrence.move", json!({"occurrence": a, "path": []})),
("occurrence.move", json!({"occurrence": a, "path": null})),
("occurrence.move", json!({"occurrence": a, "base_transform": [[1]]})),
("occurrence.move", json!({"occurrence": a, "base_transform": [[1, 0, 0, 5], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]})),
("occurrence.move", json!({"occurrence": a, "base_transform": [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 1]]})),
("occurrence.move", json!({"occurrence": a, "matrix": null})),
("occurrence.move", json!({"occurrence": a, "matrix": [1, 2, 3]})),
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "translate": [1, 0, 0]})),
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "axis": null})),
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "angle": 1})),
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "origin": at})),
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": 0})),
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": "x"})),
("occurrence.move", json!({"occurrence": a, "matrix": FLAT_MATRIX, "tolerance": 0.01})),
("occurrence.move", json!({"occurrence": a, "matrix": [2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]})),
("occurrence.move", json!({"occurrence": a, "matrix": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]})),
("occurrence.move", json!({"occurrence": a, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1]})),
("occurrence.drag", json!({})),
("occurrence.drag", json!({"path": "x", "at": at, "point": at})),
("occurrence.drag", json!({"path": [], "at": at, "point": at})),
("occurrence.drag", json!({"occurrence": null, "at": at, "point": at})),
("occurrence.drag", json!({"occurrence": a})),
("occurrence.drag", json!({"occurrence": a, "at": at})),
("occurrence.drag", json!({"occurrence": a, "point": at})),
("occurrence.drag", json!({"occurrence": a, "at": 5})),
("occurrence.drag", json!({"occurrence": a, "path": null, "at": at, "point": at})),
("occurrence.drag", json!({"occurrence": a, "path": [], "at": at, "point": at})),
("occurrence.drag", json!({"occurrence": a, "at": null, "point": at})),
("occurrence.drag", json!({"occurrence": a, "at": at, "point": [1, 2]})),
("occurrence.drag", json!({"occurrence": a, "at": [1e9, 0, 0], "point": at})),
("occurrence.properties", json!({})),
("occurrence.properties", json!({"occurrence": null})),
("occurrence.properties", json!({"occurrence": false})),
("occurrence.properties", json!({"occurrence": a, "path": []})),
("occurrence.properties", json!({"occurrence": a, "path": null})),
("occurrence.properties", json!({"occurrence": a, "descendants": "yes"})),
("occurrence.properties", json!({"occurrence": a, "descendants": null})),
("occurrence.properties", json!({"occurrence": a, "about": [1, 2]})),
("occurrence.properties", json!({"occurrence": a, "about": "origin"})),
("component.move_bodies", json!({})),
("component.move_bodies", json!({"bodies": ["ABox"]})),
("component.move_bodies", json!({"component": null, "bodies": ["ABox"]})),
("component.move_bodies", json!({"component": "B"})),
("component.move_bodies", json!({"component": "B", "bodies": null})),
("component.move_bodies", json!({"component": "B", "bodies": "ABox"})),
("component.move_bodies", json!({"component": "B", "bodies": [1]})),
("component.move_bodies", json!({"component": "B", "bodies": []})),
("component.move_bodies", json!({"component": "B", "bodies": ["ABox", "ABox"]})),
("component.move_bodies", json!({"component": "B", "bodies": vec!["ABox"; 1001]})),
("component.move_bodies", json!({"component": "B", "bodies": ["Nope"]})),
("component.move_bodies", json!({"component": "Nope", "bodies": ["ABox"]})),
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": null})),
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": "x"})),
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "component_path": vec![b; 65]})),
("component.move_bodies", json!({"component": "B", "bodies": ["ABox"], "from_path": [b, "x"]})),
("component.move_sketches", json!({})),
("component.move_sketches", json!({"sketches": ["S"]})),
("component.move_sketches", json!({"component": null, "sketches": ["S"]})),
("component.move_sketches", json!({"component": true, "sketches": ["S"]})),
("component.move_sketches", json!({"component": "B"})),
("component.move_sketches", json!({"component": "B", "sketches": null})),
("component.move_sketches", json!({"component": "B", "sketches": []})),
("component.move_sketches", json!({"component": "B", "sketches": 5})),
("component.move_sketches", json!({"component": "B", "sketches": [1]})),
("component.move_sketches", json!({"component": "B", "sketches": ["S", true]})),
("component.move_sketches", json!({"component": "B", "sketches": vec!["S"; 10_001]})),
("component.move_sketches", json!({"component": "B", "sketches": ["Nope"]})),
]
});
}
/// The contracts say what the handlers need, in the schemas: required keys are required though the
/// types leave them to the handlers' messages, and the ways a command can be called are open.
#[test]
fn the_schemas_take_what_the_occurrence_handlers_read() {
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
let schema = schema.as_value();
for (p, takes) in cases {
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
}
};
let matrix: Vec<i32> = FLAT_MATRIX.to_vec();
takes(input_schema_for::<CapturePositionParams>(), &[(json!({}), true), (json!({"x": 1}), false)]);
takes(
input_schema_for::<RevertPositionParams>(),
&[(json!({}), true), (json!({"occurrences": [1, 2]}), true), (json!({"occurrences": []}), false), (json!({"occurrences": ["1"]}), false)],
);
takes(
input_schema_for::<GroundParams>(),
&[
(json!({"occurrence": 1}), true),
(json!({"occurrence": "A:1", "path": [1], "grounded": false}), true),
(json!({}), false),
(json!({"occurrence": 1, "path": []}), false),
(json!({"occurrence": true}), false),
],
);
takes(
input_schema_for::<CopyParams>(),
&[
(json!({"occurrence": 1}), true),
(json!({"component": "A"}), true),
(json!({"occurrence": 1, "translate": {"x": 1, "y": 2, "z": 3}, "in_place": true, "parent_path": [], "path": [1], "parent": 0}), true),
(json!({}), false),
(json!({"occurrence": 1, "translate": [1, 2]}), false),
],
);
takes(
input_schema_for::<PasteNewParams>(),
&[
(json!({"component": "A"}), true),
(
json!({"component": 1, "occurrence": 2, "path": [2], "parent": "B", "translate": [1, 2, 3], "in_place": true, "parent_path": [1]}),
true,
),
(json!({"occurrence": 1}), false),
(json!({"component": "A", "translate": [1, 2]}), false),
],
);
takes(
input_schema_for::<MoveParams>(),
&[
(json!({"occurrence": 1}), true),
(json!({"occurrence": 1, "translate": [1, 2, 3], "axis": [0, 0, 1], "angle": "30 deg", "origin": [0, 0, 0], "space": "world"}), true),
(json!({"occurrence": 1, "angle": 0.5, "capture": false, "move_grounded": true, "path": [1]}), true),
(json!({"occurrence": 1, "matrix": matrix, "tolerance": 1e-6}), true),
(json!({"occurrence": 1, "matrix": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}), true),
(json!({"occurrence": 1, "base_transform": [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}), true),
(json!({}), false),
(json!({"occurrence": 1, "matrix": FLAT_MATRIX, "translate": [1, 0, 0]}), false),
(json!({"occurrence": 1, "matrix": FLAT_MATRIX, "origin": [1, 0, 0]}), false),
(json!({"occurrence": 1, "matrix": [1, 2, 3]}), false),
(json!({"occurrence": 1, "space": "up"}), false),
(json!({"occurrence": 1, "angle": true}), false),
],
);
takes(
input_schema_for::<DragParams>(),
&[
(json!({"occurrence": 1, "at": [0, 0, 0], "point": [1, 0, 0]}), true),
(json!({"path": [1, 2], "at": {"x": 0, "y": 0, "z": 0}, "point": [1, 0, 0], "continuous": true}), true),
(json!({"at": [0, 0, 0], "point": [1, 0, 0]}), false),
(json!({"occurrence": 1, "at": [0, 0, 0]}), false),
(json!({"occurrence": 1, "point": [0, 0, 0]}), false),
(json!({"occurrence": 1, "path": [], "at": [0, 0, 0], "point": [1, 0, 0]}), false),
],
);
takes(
input_schema_for::<OccurrencePropertiesParams>(),
&[
(json!({"occurrence": 1}), true),
(json!({"occurrence": "A:1", "path": [1], "descendants": false, "about": [1, 2, 3]}), true),
(json!({"occurrence": 1, "about": null}), true),
(json!({}), false),
(json!({"occurrence": 1, "descendants": 1}), false),
(json!({"occurrence": 1, "about": [1, 2]}), false),
],
);
takes(
input_schema_for::<MoveBodiesParams>(),
&[
(json!({"component": "B", "bodies": ["A"]}), true),
(json!({"component": 2, "bodies": ["A", "B"], "component_path": [1], "from_path": []}), true),
(json!({"bodies": ["A"]}), false),
(json!({"component": "B"}), false),
(json!({"component": "B", "bodies": "A"}), false),
(json!({"component": "B", "bodies": ["A"], "from_path": vec![1; 65]}), false),
],
);
takes(
input_schema_for::<MoveSketchesParams>(),
&[
(json!({"component": "B", "sketches": ["S"]}), true),
(json!({"component": "B", "sketches": "S"}), true),
(json!({"component": 2, "sketches": ["S", "T"]}), true),
(json!({"sketches": ["S"]}), false),
(json!({"component": "B"}), false),
(json!({"component": "B", "sketches": []}), false),
(json!({"component": "B", "sketches": [1]}), false),
],
);
}
/// The types read each key as its handler does: keys the handlers read only in some cases are
/// read only in those, and a wrong key says what is wrong with it in the handler's words.
#[test]
fn the_check_reads_the_occurrence_keys_as_the_handlers_do() {
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
let none = Vec::<String>::new();
// `occurrence.copy`: `component` only without `occurrence`, `parent_path` only with `in_place: true`.
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "component": [1], "parent_path": "x"})), none);
assert_eq!(issues("occurrence.copy", json!({"component": [1]})), ["`component` must be a component id or name"]);
assert_eq!(issues("occurrence.copy", json!({"occurrence": null, "component": 1})), ["`occurrence` must be an id or name"]);
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": false, "parent_path": "x"})), none);
assert_eq!(
issues("occurrence.copy", json!({"occurrence": 1, "in_place": true, "parent_path": "x"})),
["`parent_path` must list 0..64 occurrence ids"]
);
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": true, "parent_path": []})), none);
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "translate": [1, 2]})), ["translate must contain three finite numbers"]);
assert_eq!(issues("occurrence.copy", json!({"occurrence": 1, "in_place": "yes"})).len(), 1);
assert_eq!(issues("occurrence.copy", json!({"occurence": 1})), ["unknown key `occurence`; did you mean `occurrence`?"]);
// `component.paste_new`: `parent_path` only with `in_place: true`.
assert_eq!(issues("component.paste_new", json!({"component": 1, "parent_path": 5})), none);
assert_eq!(
issues("component.paste_new", json!({"component": 1, "in_place": true, "parent_path": 5})),
["`parent_path` must list 0..64 occurrence ids"]
);
assert_eq!(issues("component.paste_new", json!({"component": 1, "occurrence": 2, "path": [2], "translate": {"x": 1, "y": 2, "z": 3}})), none);
assert_eq!(issues("component.paste_new", json!({"component": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
assert_eq!(issues("component.paste_new", json!({"component": 1, "parent": null})), ["`parent` must be a component id or name"]);
// `occurrence.move`: `tolerance` only with `matrix`, which takes the place of the move keys.
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "tolerance": "x"})), none);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "tolerance": 1e-6})), none);
assert_eq!(
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "tolerance": "x"})),
["tolerance must be a number above 0 and at most 0.001"]
);
assert_eq!(
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "translate": "x"})),
["give either matrix or translate, not both"]
);
assert_eq!(
issues("occurrence.move", json!({"occurrence": 1, "matrix": FLAT_MATRIX, "origin": null})),
["give either matrix or origin, not both"]
);
assert!(issues("occurrence.move", json!({"occurrence": 1, "matrix": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}))[0].starts_with("matrix mirrors"));
assert_eq!(
issues("occurrence.move", json!({"occurrence": 1, "matrix": [1]})),
["matrix must be 12 numbers (3x4, row by row) or 16 (4x4), flat or as rows"]
);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "translate": [1, 2]})), ["translate must contain three finite numbers"]);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "axis": null})), ["axis must contain three finite numbers"]);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "origin": "x"})), ["origin must contain three finite numbers"]);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "angle": null})), ["angle must be a number or angle expression"]);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "angle": "30 deg", "axis": {"x": 0, "y": 1, "z": 0}})), none);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "space": "World"})), ["space must be local or world"]);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "base_transform": [[1]]})), ["base_transform must be a 4x4 affine matrix"]);
assert_eq!(
issues("occurrence.move", json!({"occurrence": 1, "base_transform": [[1, 0, 0, 2], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]})),
["base_transform must be finite, affine and invertible"]
);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "capture": "no"})).len(), 1);
assert_eq!(issues("occurrence.move", json!({"occurrence": 1, "translte": [1, 0, 0]})), ["unknown key `translte`; did you mean `translate`?"]);
// `occurrence.drag`: `path` stands in for `occurrence`, and `at` and `point` come together.
let at = json!([0, 0, 0]);
assert_eq!(issues("occurrence.drag", json!({"path": [1], "at": at, "point": at})), none);
assert_eq!(issues("occurrence.drag", json!({"at": at, "point": at})), ["give `occurrence` or `path` (1..64 occurrence ids)"]);
assert_eq!(issues("occurrence.drag", json!({"path": [], "at": at, "point": at})), ["give `occurrence` or `path` (1..64 occurrence ids)"]);
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "path": [], "at": at, "point": at})), ["`path` must list 1..64 occurrence ids"]);
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": at})), ["`at` and `point` must both be given"]);
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": 5})), ["`at` and `point` must both be given"]);
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": 5, "point": at})), ["at must contain three finite numbers"]);
assert_eq!(issues("occurrence.drag", json!({"occurrence": 1, "at": at, "point": [1]})), ["point must contain three finite numbers"]);
// The rest.
assert_eq!(issues("component.revert_position", json!({"occurrences": []})), ["occurrences must list 1..10000 ids"]);
assert_eq!(issues("component.revert_position", json!({"occurrences": null})), ["occurrences must list 1..10000 ids"]);
assert_eq!(issues("component.capture_position", json!({"capture": true})), ["unknown key `capture`"]);
assert_eq!(issues("occurrence.ground", json!({"occurrence": 1, "grounded": null})), none);
assert_eq!(issues("occurrence.ground", json!({"occurrence": 1, "path": []})), ["`path` must list 1..64 occurrence ids"]);
assert_eq!(issues("occurrence.properties", json!({"occurrence": 1, "about": [1]})), ["`about` must be a point [x, y, z] in mm"]);
assert_eq!(issues("occurrence.properties", json!({"occurrence": 1, "descendants": null})), ["`descendants` must be a boolean"]);
assert_eq!(issues("component.move_bodies", json!({"component": 1, "bodies": "A"})), ["bodies must be an array of body names"]);
assert_eq!(
issues("component.move_bodies", json!({"component": 1, "bodies": ["A"], "from_path": null})),
["`from_path` must list 0..64 occurrence ids"]
);
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": 5})), ["`sketches` must be a list of names; it is 5"]);
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": ["S", 3]})), ["`sketches` must list names; the entry 2 is 3"]);
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": null})), ["`sketches` must list sketches"]);
assert_eq!(issues("component.move_sketches", json!({"component": 1, "sketches": "S"})), none);
}
/// A part set up with its placements: the design's grounded `Base`, a free `Plate` (a box, and
/// the sketch `S`) and an empty free `Spare`.
fn plates(s: &mut Session) -> (Value, Value, Value) {
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
let base = run("component.create", json!({"name": "Base"}));
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "BaseBox"}));
run("component.activate", json!({"component": 0}));
let plate = run("component.create", json!({"name": "Plate", "parent": 0, "grounded": false}));
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "PlateBox"}));
run("sketch.create", json!({"name": "S", "plane": "XY"}));
run("sketch.rectangle.two_point", json!({"p0": [21, 1], "p1": [23, 3]}));
run("sketch.finish", json!({}));
run("component.activate", json!({"component": 0}));
let spare = run("component.create", json!({"name": "Spare", "parent": 0, "grounded": false, "activate": false}));
(base, plate, spare)
}
/// Every command, in each form it takes, in Warn: the real reply matches its output schema and
/// carries no `param_warnings` (Warn runs a call the contract refuses anyway, so the replies are
/// also checked for warnings), and a key spelt wrongly on purpose is reported.
#[test]
fn replies_match_the_output_schemas_of_the_occurrence_commands() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let (base, plate, spare) = plates(&mut s);
let (base_at, pl, spare_at) = (base["occurrence"].clone(), plate["occurrence"].clone(), spare["occurrence"].clone());
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
// The placement's name, to give it by name.
let listed = run("component.list", json!({}));
let plate_name = listed["components"]
.as_array()
.into_iter()
.flatten()
.flat_map(|c| c["occurrences"].as_array().into_iter().flatten())
.find(|o| o["id"] == pl)
.map(|o| o["name"].clone())
.unwrap_or_default();
assert!(plate_name.is_string(), "{listed}");
// Move Occurrence, in each form.
let identity = json!([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]);
for p in [
json!({"occurrence": pl, "translate": [1, 0, 0]}),
json!({"occurrence": pl, "translate": {"x": 0, "y": 1, "z": 0}, "space": "local"}),
json!({"occurrence": pl, "space": "world", "path": [pl], "translate": [0, 0, 1]}),
json!({"occurrence": pl, "path": [pl], "translate": [0, 0, -1]}),
json!({"occurrence": pl, "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]}),
json!({"occurrence": pl, "axis": {"x": 0, "y": 1, "z": 0}, "angle": 0.5, "origin": {"x": 1, "y": 1, "z": 1}}),
json!({"occurrence": pl, "angle": 0}),
json!({"occurrence": pl, "tolerance": "ignored without a matrix"}),
json!({"occurrence": pl, "matrix": FLAT_MATRIX}),
json!({"occurrence": pl, "matrix": FLAT_MATRIX, "tolerance": 1e-6, "space": "world", "path": [pl]}),
json!({"occurrence": pl, "matrix": [[1, 0, 0, 1], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]}),
json!({"occurrence": pl, "base_transform": identity, "translate": [3, 0, 0]}),
json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}),
json!({"occurrence": base_at, "translate": [0, 0, 0], "move_grounded": true}),
] {
let moved = run("occurrence.move", p.clone());
matches(output_schema_for::<MoveResult>(), "occurrence.move", &moved);
assert_eq!(moved["pending"], p.get("capture") == Some(&json!(false)), "{p}: {moved}");
}
// Capture and Revert Position.
let captured = run("component.capture_position", json!({}));
assert_eq!(captured["captured"], 1, "{captured}");
matches(output_schema_for::<CapturePositionResult>(), "component.capture_position", &captured);
let captured = run("component.capture_position", json!({}));
assert_eq!(captured["captured"], 0, "{captured}");
matches(output_schema_for::<CapturePositionResult>(), "component.capture_position", &captured);
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
let reverted = run("component.revert_position", json!({"occurrences": [pl]}));
assert_eq!(reverted["reverted"], 1, "{reverted}");
matches(output_schema_for::<RevertPositionResult>(), "component.revert_position", &reverted);
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
let reverted = run("component.revert_position", json!({}));
assert_eq!(reverted["reverted"], 1, "{reverted}");
matches(output_schema_for::<RevertPositionResult>(), "component.revert_position", &reverted);
// Ground.
for p in [
json!({"occurrence": pl}),
json!({"occurrence": pl, "grounded": true, "path": [pl]}),
json!({"occurrence": pl, "grounded": null}),
json!({"occurrence": pl, "grounded": false}),
] {
let grounded = run("occurrence.ground", p.clone());
matches(output_schema_for::<GroundResult>(), "occurrence.ground", &grounded);
assert_eq!(grounded["occurrence"], pl);
}
// Copy (an instance), in each form. What the handler doesn't read for the form is no problem.
for p in [
json!({"component": "Plate"}),
json!({"component": plate["component"], "translate": [0, 30, 0], "parent": 0}),
json!({"occurrence": pl, "translate": [0, 40, 0]}),
json!({"occurrence": pl, "component": [1, 2], "parent_path": "ignored"}),
json!({"occurrence": plate_name, "path": [pl], "translate": {"x": 0, "y": 0, "z": 5}}),
json!({"occurrence": pl, "path": [pl], "in_place": true, "parent_path": [], "translate": [0, 0, 5]}),
json!({"occurrence": pl, "in_place": false, "parent_path": "ignored", "parent": 0}),
] {
let copy = run("occurrence.copy", p.clone());
matches(output_schema_for::<CopyResult>(), "occurrence.copy", &copy);
assert_eq!(copy["component"], plate["component"], "{p}: {copy}");
}
// Paste New.
for p in [
json!({"component": "Plate"}),
json!({"component": plate["component"], "occurrence": pl, "translate": [0, 0, 60], "parent": 0}),
json!({"component": "Plate", "occurrence": pl, "path": [pl]}),
json!({"component": "Plate", "parent_path": "ignored"}),
json!({"component": "Plate", "in_place": true, "parent_path": [], "translate": {"x": 5, "y": 0, "z": 0}}),
] {
let pasted = run("component.paste_new", p.clone());
matches(output_schema_for::<PasteNewResult>(), "component.paste_new", &pasted);
assert!(pasted["bodies"].as_u64().is_some_and(|n| n >= 1) && pasted["component"] != plate["component"], "{p}: {pasted}");
}
// Drag.
for p in [
json!({"occurrence": pl, "at": [25, 5, 5], "point": [25, 15, 5]}),
json!({"path": [pl], "at": {"x": 25, "y": 15, "z": 5}, "point": {"x": 25, "y": 5, "z": 5}, "continuous": true}),
json!({"occurrence": pl, "path": [pl], "at": [0, 0, 0], "point": [0, 0, 0]}),
] {
let dragged = run("occurrence.drag", p.clone());
matches(output_schema_for::<DragResult>(), "occurrence.drag", &dragged);
assert_eq!(dragged["continuous"], p.get("continuous") == Some(&json!(true)), "{p}: {dragged}");
}
// Occurrence Physical Properties.
for p in [
json!({"occurrence": pl}),
json!({"occurrence": pl, "path": [pl], "descendants": false}),
json!({"occurrence": pl, "about": [1, 2, 3]}),
json!({"occurrence": plate_name, "about": {"x": 1, "y": 2, "z": 3}, "descendants": true}),
json!({"occurrence": base_at, "about": null}),
] {
let measured = run("occurrence.properties", p.clone());
matches(output_schema_for::<OccurrencePropertiesResult>(), "occurrence.properties", &measured);
assert!(measured["name"].is_string() && measured["total"]["volume_mm3"].as_f64().is_some_and(|v| v > 0.0), "{p}: {measured}");
assert_eq!(measured["total"].get("inertia_about").is_some(), p.get("about").is_some_and(|a| !a.is_null()), "{p}: {measured}");
}
// Move Bodies to Component, and Move Sketches to Component.
let moved = run("component.move_bodies", json!({"bodies": ["BaseBox"], "component": "Spare"}));
assert_eq!(moved["features"].as_array().map(Vec::len), Some(1), "{moved}");
matches(output_schema_for::<MoveBodiesResult>(), "component.move_bodies", &moved);
let moved =
run("component.move_bodies", json!({"bodies": ["BaseBox"], "component": "Base", "component_path": [base_at], "from_path": [spare_at]}));
matches(output_schema_for::<MoveBodiesResult>(), "component.move_bodies", &moved);
let moved = run("component.move_sketches", json!({"sketches": ["S"], "component": "Spare"}));
assert_eq!(moved["component"], spare["component"], "{moved}");
matches(output_schema_for::<MoveSketchesResult>(), "component.move_sketches", &moved);
let moved = run("component.move_sketches", json!({"sketches": "S", "component": "Base"}));
matches(output_schema_for::<MoveSketchesResult>(), "component.move_sketches", &moved);
// Misspelt keys still run in Warn, say so, and the reply still matches.
run("occurrence.move", json!({"occurrence": pl, "capture": false, "translate": [0, 2, 0]}));
let cases: [(&str, Value, &str, schemars::Schema); 9] = [
("component.capture_position", json!({"captrue": true}), "captrue", output_schema_for::<CapturePositionResult>()),
("component.revert_position", json!({"occurence": [pl]}), "occurence", output_schema_for::<RevertPositionResult>()),
("occurrence.copy", json!({"component": "Plate", "translat": [1, 0, 0]}), "translat", output_schema_for::<CopyResult>()),
("component.paste_new", json!({"component": "Plate", "paretn": 0}), "paretn", output_schema_for::<PasteNewResult>()),
("occurrence.move", json!({"occurrence": pl, "translte": [1, 0, 0]}), "translte", output_schema_for::<MoveResult>()),
(
"occurrence.drag",
json!({"occurrence": pl, "at": [0, 0, 0], "point": [1, 0, 0], "contiuous": true}),
"contiuous",
output_schema_for::<DragResult>(),
),
("occurrence.properties", json!({"occurrence": pl, "descendents": false}), "descendents", output_schema_for::<OccurrencePropertiesResult>()),
(
"component.move_bodies",
json!({"bodies": ["BaseBox"], "component": "Spare", "compnent_path": [spare_at]}),
"compnent_path",
output_schema_for::<MoveBodiesResult>(),
),
(
"component.move_sketches",
json!({"sketches": ["S"], "component": "Plate", "componnt": 1}),
"componnt",
output_schema_for::<MoveSketchesResult>(),
),
];
for (id, p, key, schema) in cases {
let warned = run(id, p);
let warnings = warned["param_warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains(key)), "{id}: {warned}");
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{id}: {warned}");
}
// Last, as it toggles the placement's grounding.
let warned = run("occurrence.ground", json!({"occurrence": pl, "grouned": true}));
let schema = output_schema_for::<GroundResult>();
assert!(warned["param_warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("grouned"))), "{warned}");
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
}
/// A move that joints undo replies with `reverted` and a `warning`, which the output schema
/// describes.
#[test]
fn a_move_its_joints_undo_matches_the_output_schema() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut run = |id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
let base = run("component.create", json!({"name": "Base"}))["occurrence"].clone();
run("solid.box", json!({"length": 10, "width": 10, "height": 10}));
run("component.activate", json!({"component": 0}));
let plate = run("component.create", json!({"name": "Plate", "parent": 0, "grounded": false}))["occurrence"].clone();
run("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
run("component.activate", json!({"component": 0}));
run("joint.as_built", json!({"type": "rigid", "a": base, "b": plate, "name": "Mount"}));
let moved = run("occurrence.move", json!({"occurrence": plate, "translate": [0, 0, 30]}));
assert_eq!(moved["reverted"], true, "{moved}");
assert!(moved["warning"].as_str().is_some_and(|w| w.contains("Mount")), "{moved}");
matches(output_schema_for::<MoveResult>(), "occurrence.move", &moved);
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -529,7 +529,8 @@ pub struct FilletParams {
/// The radius; the chord's width with `type: chord`, the equivalent circular setback with
/// `type: curvature`, the start radius with `type: variable` or `spline`.
pub radius: LengthExpr,
/// The body whose edges the points pick. Default: whichever body has an edge there.
/// The body whose edges the points pick. Default: each point is looked up on every placed
/// body, shown or hidden, and a point on edges of 2 bodies is refused, naming them.
#[serde(default)]
pub body: Option<String>,
/// Round each picked edge's tangent chain with it. Default: true; false rounds exactly the
File diff suppressed because it is too large Load Diff
+944 -4
View File
@@ -3,9 +3,10 @@ use solidcore_record::Vec3;
use solidcraft_doc::joints::JointKind;
use super::{
AsBuiltJointParams, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams, DriveJointResult, EditJointParams,
InspectJointParams, InspectJointResult, JointAddedResult, JointKindName, JointLimitsParams, ListJointsParams, ListJointsResult,
SolveJointsParams, SolveResult,
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams,
DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName, JointLimitsParams,
ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams, ReachParams, ReachResult,
RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
@@ -25,11 +26,39 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ListJointsParams>(), &["quiet"]);
assert_keys(input_schema_for::<InspectJointParams>(), &["joint"]);
assert_keys(input_schema_for::<SolveJointsParams>(), &["quiet"]);
assert_keys(
input_schema_for::<MakeJointOriginParams>(),
&["body", "circle", "corner", "edge", "end", "face", "frame", "name", "occurrence", "on", "point", "x", "z"],
);
assert_keys(input_schema_for::<RigidGroupParams>(), &["occurrences"]);
assert_keys(input_schema_for::<ReachParams>(), &["at", "axis", "continuous", "joints", "occurrence", "point", "quiet", "z"]);
assert_keys(input_schema_for::<MotionLinkParams>(), &["a", "b", "ia", "ib", "offset", "quiet", "ratio"]);
assert_keys(input_schema_for::<GearParams>(), &["a", "b", "internal", "link", "quiet", "ra", "rb", "reverse"]);
assert_keys(input_schema_for::<RackPinionParams>(), &["a", "b", "link", "quiet", "radius", "reverse"]);
assert_keys(input_schema_for::<ScrewParams>(), &["a", "b", "joint", "link", "pitch", "quiet", "reverse"]);
assert_keys(input_schema_for::<CamParams>(), &["a", "b", "flip", "follower", "link", "profile", "quiet", "radius", "tip"]);
}
#[test]
fn the_commands_publish_their_schemas() {
for id in ["joint.create", "joint.as_built", "joint.drive", "joint.limits", "joint.delete", "joint.list", "joint.inspect", "joint.solve"] {
for id in [
"joint.create",
"joint.as_built",
"joint.drive",
"joint.limits",
"joint.delete",
"joint.list",
"joint.inspect",
"joint.solve",
"joint.origin",
"joint.rigid_group",
"joint.reach",
"joint.motion_link",
"joint.gear",
"joint.rack_pinion",
"joint.screw",
"joint.cam",
] {
let spec = crate::cmd::find_command(id).unwrap_or_else(|| panic!("{id}"));
assert!(spec.contract.is_some() && spec.output.is_some(), "{id}");
}
@@ -113,6 +142,66 @@ fn strict_keeps_todays_errors() {
("joint.delete", json!({"link": null})),
("joint.inspect", json!({"joint": true})),
("joint.inspect", json!({"joint": [1]})),
// joint.origin
("joint.origin", json!({})),
("joint.origin", json!({"face": [1, 2]})),
("joint.origin", json!({"face": 7, "name": "N"})),
("joint.origin", json!({"frame": {"z": [0, 0, 1]}})),
("joint.origin", json!({"frame": null})),
("joint.origin", json!({"face": "", "occurrence": 0})),
("joint.origin", json!({"circle": "x".repeat(16385)})),
("joint.origin", json!({"point": [0, 0, 0], "occurrence": null})),
("joint.origin", json!({"point": [0, 0, 0], "occurrence": true})),
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "x": "nonsense"})),
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "x": [1, 0, 0], "end": "F1"})),
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "end": 5})),
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "end": null})),
("joint.origin", json!({"point": [0, 0, 0], "occurrence": 0, "x": [0, 0, 0]})),
("joint.origin", json!({"face": [0, 0, 0], "occurrence": 0, "x": {"face": null}})),
("joint.origin", json!({"face": [0, 0, 0], "occurrence": 0, "x": {}})),
("joint.origin", json!({"face": [0, 0, 0], "occurrence": 0, "end": "F1"})),
("joint.origin", json!({"circle": [0, 0, 0], "occurrence": 0, "z": [0, 0, 0]})),
("joint.origin", json!({"circle": [0, 0, 0], "occurrence": 0, "z": "up"})),
("joint.origin", json!({"edge": [0, 0, 0], "occurrence": 0, "z": 5})),
("joint.origin", json!({"frame": {"origin": [0, 0, 0]}, "occurrence": 0})),
// joint.rigid_group
("joint.rigid_group", json!({})),
("joint.rigid_group", json!({"occurrences": null})),
("joint.rigid_group", json!({"occurrences": "A"})),
("joint.rigid_group", json!({"occurrences": [0]})),
("joint.rigid_group", json!({"occurrences": vec![0; 1001]})),
("joint.rigid_group", json!({"occurrences": [0, true]})),
// joint.reach
("joint.reach", json!({})),
("joint.reach", json!({"occurrence": null, "point": [0, 0, 0]})),
("joint.reach", json!({"occurrence": [0], "point": [0, 0, 0]})),
("joint.reach", json!({"occurrence": 0})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0]})),
("joint.reach", json!({"occurrence": 0, "point": [1e9, 0, 0]})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "at": null})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "at": "here"})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 0]})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "z": null})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "z": [0, 0, 1], "axis": 5})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "axis": [0, 0, 1]})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "joints": "Hinge"})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "joints": null})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "joints": [true]})),
("joint.reach", json!({"occurrence": 0, "point": [0, 0, 0], "joints": vec![1; 1001]})),
// joint.motion_link, joint.gear, joint.rack_pinion, joint.screw and joint.cam
("joint.motion_link", json!({})),
("joint.motion_link", json!({"a": true, "b": 1, "ratio": 1})),
("joint.motion_link", json!({"a": null, "b": 1, "ratio": 1})),
("joint.gear", json!({})),
("joint.gear", json!({"a": [1], "b": 1})),
("joint.rack_pinion", json!({})),
("joint.rack_pinion", json!({"a": {}, "b": 1})),
("joint.screw", json!({})),
("joint.screw", json!({"joint": null})),
("joint.screw", json!({"joint": true, "pitch": 1})),
("joint.screw", json!({"a": true, "b": 1, "pitch": 1})),
("joint.cam", json!({})),
("joint.cam", json!({"a": false, "b": 1})),
]);
}
@@ -705,3 +794,854 @@ fn strict_may_name_another_problem_first_and_refuses_ignored_wrong_types() {
assert!(works(ParamCheck::Off) && !works(ParamCheck::Strict), "{id} {p}");
}
}
// ---------------------------------------------------------------------------------------------
// joint.origin, joint.rigid_group, joint.reach, joint.motion_link, joint.gear, joint.rack_pinion,
// joint.screw and joint.cam (#13185)
// ---------------------------------------------------------------------------------------------
fn put(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// A joint between two parts as they are, at a world point with its z along `z`.
fn pin(s: &mut Session, kind: &str, name: &str, base: &str, part: &str, p: [f64; 3], z: [f64; 3]) {
let at = |o: &str| json!({"occurrence": o, "point": p, "z": z});
put(s, "joint.create", json!({"type": kind, "a": at(base), "b": at(part), "flip": true, "name": name}));
}
/// A grounded base with two discs on hinges (G1, G2), a pinion's hinge (P) and its rack's slider
/// (Rack), a screw's hinge and slider (K, Slide2), a cylindrical joint (Nut) and a spare hinge (X):
/// a joint named for each part, for the couplings to tie.
fn rig(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
let root = |s: &mut Session| put(s, "component.activate", json!({"component": "root"}));
root(&mut s);
let base = put(&mut s, "component.create", json!({"name": "Base", "grounded": false}));
put(&mut s, "solid.box", json!({"length": 200, "width": 120, "height": 20, "corner": [-100, -60, -20]}));
put(&mut s, "occurrence.ground", json!({"occurrence": base["occurrence"], "grounded": true}));
for (name, x, r) in [("G1", 0.0, 10.0), ("G2", 30.0, 20.0)] {
root(&mut s);
put(&mut s, "component.create", json!({"name": name, "grounded": false}));
put(&mut s, "solid.cylinder", json!({"base": [x, 0, 0], "axis": [0, 0, 1], "radius": r, "height": 5}));
}
for (name, x, y) in
[("P", -40.0, -5.0), ("Rack", -40.0, -35.0), ("K", 70.0, -5.0), ("Slide2", 70.0, 25.0), ("Nut", 100.0, -5.0), ("X", 130.0, -5.0)]
{
root(&mut s);
put(&mut s, "component.create", json!({"name": name, "grounded": false}));
put(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, y, 0]}));
}
let (z, x) = ([0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
pin(&mut s, "revolute", "G1", "Base", "G1", [0.0, 0.0, 0.0], z);
pin(&mut s, "revolute", "G2", "Base", "G2", [30.0, 0.0, 0.0], z);
pin(&mut s, "revolute", "P", "Base", "P", [-40.0, 0.0, 0.0], z);
pin(&mut s, "slider", "Rack", "Base", "Rack", [-40.0, -30.0, 0.0], x);
pin(&mut s, "revolute", "K", "Base", "K", [70.0, 0.0, 0.0], z);
pin(&mut s, "slider", "Slide2", "Base", "Slide2", [70.0, 30.0, 0.0], x);
pin(&mut s, "cylindrical", "Nut", "Base", "Nut", [100.0, 0.0, 0.0], z);
pin(&mut s, "revolute", "X", "Base", "X", [130.0, 0.0, 0.0], z);
s
}
/// Grounded base, a cam (a disc of radius 30 about (10, 0), in sketch `CamSketch`) on a hinge
/// `Cam`, a tappet on a slider `Tappet` and an arm on a hinge `Arm`, for `joint.cam`.
fn cam_rig(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
put(&mut s, "component.activate", json!({"component": "root"}));
let base = put(&mut s, "component.create", json!({"name": "Base", "grounded": false}));
put(&mut s, "solid.box", json!({"length": 180, "width": 180, "height": 20, "corner": [-90, -90, -20]}));
put(&mut s, "occurrence.ground", json!({"occurrence": base["occurrence"], "grounded": true}));
put(&mut s, "component.activate", json!({"component": "root"}));
put(&mut s, "component.create", json!({"name": "Cam"}));
put(&mut s, "sketch.create", json!({"plane": "XY", "name": "CamSketch"}));
put(&mut s, "sketch.circle.center", json!({"center": [10, 0], "radius": 30, "id": "c1"}));
put(&mut s, "sketch.finish", json!({}));
put(&mut s, "solid.extrude", json!({"distance": 5}));
pin(&mut s, "revolute", "Cam", "Base", "Cam", [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
put(&mut s, "component.activate", json!({"component": "root"}));
put(&mut s, "component.create", json!({"name": "Tappet"}));
put(&mut s, "solid.box", json!({"length": 20, "width": 4, "height": 5, "corner": [45, -2, 0]}));
pin(&mut s, "slider", "Tappet", "Base", "Tappet", [0.0, 0.0, 2.5], [1.0, 0.0, 0.0]);
put(&mut s, "component.activate", json!({"component": "root"}));
put(&mut s, "component.create", json!({"name": "Arm"}));
put(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 5, "corner": [-52, 38, 0]}));
pin(&mut s, "revolute", "Arm", "Base", "Arm", [-50.0, 40.0, 2.5], [0.0, 0.0, 1.0]);
s
}
/// Both of `rig`'s sessions give a call the same error, with the check off and in strict.
fn assert_strict_keeps_todays_errors_in(rig: fn(ParamCheck) -> Session, cases: &[(&str, Value)]) {
let (mut off, mut strict) = (rig(ParamCheck::Off), rig(ParamCheck::Strict));
for (id, p) in cases {
let today = off.execute(id, p).err().map(|e| e.to_string());
assert!(today.is_some(), "{id} {p} should fail today");
assert_eq!(strict.execute(id, p).err().map(|e| e.to_string()), today, "{id} {p}");
}
}
/// What the handlers refuse with a design to refuse it for: strict says the same.
#[test]
fn strict_keeps_todays_errors_with_the_couplings() {
let long = "x".repeat(1001);
assert_strict_keeps_todays_errors_in(
rig,
&[
("joint.motion_link", json!({"a": "G1", "b": "G1", "ratio": 1})),
("joint.motion_link", json!({"a": "G1", "b": "G2"})),
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": null})),
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": "2"})),
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": 1e12})),
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": 1, "offset": true})),
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": 1, "offset": null})),
("joint.motion_link", json!({"a": "G1", "b": "G2", "ratio": 1, "offset": 1e12})),
("joint.motion_link", json!({"a": "Missing", "b": "G2", "ratio": 1})),
("joint.gear", json!({"a": "G1", "b": "G1", "ra": 1, "rb": 1})),
("joint.gear", json!({"a": "G1", "b": "G2"})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": true, "rb": 1})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": null, "rb": 1})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": long, "rb": 1})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": {"circle": 5}})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": {"circle": ""}})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": {"point": [1, 2, 3]}})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": -1})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": null})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": "0"})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 1, "rb": 1, "link": 0})),
("joint.gear", json!({"a": "G1", "b": "Rack", "ra": 1, "rb": 1})),
("joint.rack_pinion", json!({"a": "P", "b": "Rack"})),
("joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": []})),
("joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": 1, "link": 1.5})),
("joint.rack_pinion", json!({"a": "P", "b": "G1", "radius": 1})),
("joint.screw", json!({"joint": "Nut"})),
("joint.screw", json!({"joint": "Nut", "pitch": {}})),
("joint.screw", json!({"joint": "Nut", "pitch": 1, "link": "x"})),
("joint.screw", json!({"joint": "G1", "pitch": 1})),
("joint.screw", json!({"joint": 99, "pitch": 1})),
("joint.screw", json!({"a": "K", "b": "Slide2", "pitch": null})),
("joint.screw", json!({"a": "K", "b": "K", "pitch": 1})),
("joint.screw", json!({"a": "K", "pitch": 1})),
("joint.reach", json!({"occurrence": "G1", "point": [0, 0, 0], "joints": ["G1", true]})),
("joint.reach", json!({"occurrence": "Missing", "point": [0, 0, 0]})),
("joint.origin", json!({"occurrence": "Base", "face": "Missing"})),
("joint.origin", json!({"occurrence": "Missing", "point": [0, 0, 0]})),
],
);
}
/// What `joint.cam` refuses, with a cam and its followers in the design.
#[test]
fn strict_keeps_todays_errors_for_the_cam() {
let edges = |n: usize| json!({"edges": vec![[10, 30, 5]; n]});
assert_strict_keeps_todays_errors_in(
cam_rig,
&[
("joint.cam", json!({"a": "Cam", "b": "Cam", "profile": {"sketch": "CamSketch"}})),
("joint.cam", json!({"a": "Tappet", "b": "Cam", "profile": {"sketch": "CamSketch"}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet"})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": null})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": "CamSketch"})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": null}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": true}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": "0"}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": -1}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": null}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": 7}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "Missing"}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": []}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": "x"}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": edges(65)})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[1, 2]]}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[10, 30, 5]], "occurrence": null}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[10, 30, 5]], "occurrence": [1]}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "round"})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller"})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": true})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": null})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": {"circle": 5}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": 5})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": null})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "tip": {"face": ""}})),
("joint.cam", json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "link": 3})),
("joint.cam", json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}})),
],
);
}
/// The couplings, reach, rigid groups and joint origins read each key as their handlers do, and in
/// the cases the handlers read it.
#[test]
fn the_remaining_types_read_each_key_as_the_handlers_do() {
let none = Vec::<String>::new;
let why = "a joint origin needs face, circle, edge, point or frame [x, y, z], or a face's name";
let x_form = "`x` is a direction [x, y, z], a face's name, or {face: [x, y, z]}";
let end_name = "`end` is the name of a face a round end lies on";
let end_round = "`end` is for a round origin (`circle`)";
let origin = |p: Value| issues("joint.origin", p);
// joint.origin: the forms, each reading only its own keys.
for p in [
json!({"face": [1, 2, 3]}),
json!({"face": {"x": 1, "y": 2, "z": 3}, "occurrence": "A", "x": [1, 0, 0], "name": "N"}),
json!({"face": "F1"}),
json!({"face": "F1", "x": "F2", "body": "B", "occurrence": 3, "name": null}),
json!({"face": "F1", "x": {"face": [1, 2, 3]}}),
json!({"face": "F1", "x": {"face": "F2"}}),
json!({"face": "F1", "x": null, "z": "junk", "on": 1, "corner": 2, "frame": 3, "edge": 4, "point": 5}),
json!({"circle": "F1", "end": "F2", "x": "F3"}),
json!({"circle": [1, 2, 3], "end": "F2", "x": [0, 1, 0]}),
// A circle with a z: its end is tried and then a point, and neither refuses a bad end.
json!({"circle": [1, 2, 3], "z": [0, 0, 1], "end": 5}),
json!({"circle": [1, 2, 3], "z": {"x": 0, "y": 0, "z": 1}, "end": "F2"}),
json!({"circle": [1, 2, 3], "z": null}),
// An edge reads on, corner and z; with a z, end is not read.
json!({"edge": [1, 2, 3]}),
json!({"edge": [1, 2, 3], "on": [0, 0, 0], "corner": [1, 1, 1], "z": [0, 0, 1], "end": 7, "x": "junk", "body": 3}),
json!({"edge": [1, 2, 3], "on": null, "corner": null, "z": null}),
json!({"point": [1, 2, 3]}),
json!({"point": [1, 2, 3], "z": [0, 0, 1], "x": [1, 0, 0], "on": "junk"}),
json!({"point": [1, 2, 3], "z": [0, 0, 0], "x": null}),
json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1]}}),
json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1], "x": [1, 0, 0]}, "x": "junk", "z": "junk", "on": 1}),
json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1], "x": null}}),
// A point wins over a frame; a name over a point; the first of face and circle given by name.
json!({"point": [1, 2, 3], "frame": 5}),
json!({"face": [1, 2], "point": [1, 2, 3]}),
json!({"face": [1, 2, 3], "circle": "C", "end": "E"}),
json!({"face": null, "circle": null, "point": [1, 2, 3]}),
] {
assert_eq!(origin(p.clone()), none(), "{p}");
}
let refused = [
(json!({}), why),
(json!({"name": 5}), why),
(json!({"face": [1, 2]}), why),
(json!({"face": 7, "circle": null}), why),
(json!({"point": "x"}), why),
(json!({"frame": null}), "`frame` needs origin and z"),
(json!({"frame": {"z": [0, 0, 1]}}), "`frame` needs origin and z"),
(json!({"frame": {"origin": [0, 0, 0]}}), "`frame` needs a z axis"),
(json!({"frame": {"origin": [0, 0, 0], "z": [0, 0]}}), "`frame` needs a z axis"),
(json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1], "x": 5}}), "`frame`'s x must be a direction [x, y, z]"),
(json!({"face": ""}), "a face name is 1…16384 characters"),
(json!({"circle": "x".repeat(16385)}), "a face name is 1…16384 characters"),
(json!({"face": "F", "body": 5}), "`body` must be text"),
(json!({"face": "F", "occurrence": true}), "an occurrence must be an id or a name"),
(json!({"face": "F", "occurrence": null}), "an occurrence must be an id or a name"),
(json!({"point": [1, 2, 3], "occurrence": [1]}), "an occurrence must be an id or a name"),
(json!({"face": "F", "x": 5}), x_form),
(json!({"face": "F", "x": ""}), x_form),
(json!({"face": "F", "x": [0, 0, 0]}), x_form),
(json!({"face": "F", "x": {}}), x_form),
(json!({"face": "F", "x": {"face": null}}), x_form),
(json!({"face": "F", "x": {"face": ""}}), x_form),
(json!({"face": [1, 2, 3], "x": true}), x_form),
(json!({"circle": [1, 2, 3], "x": "x".repeat(16385)}), x_form),
(json!({"face": "F", "end": "E"}), end_round),
(json!({"face": [1, 2, 3], "end": "E"}), end_round),
(json!({"point": [1, 2, 3], "end": "E"}), end_round),
(json!({"edge": [1, 2, 3], "end": "E"}), end_round),
(json!({"edge": [1, 2, 3], "z": null, "end": "E"}), end_round),
(json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1]}, "end": "E"}), end_round),
(json!({"point": [1, 2, 3], "end": 5}), end_name),
(json!({"point": [1, 2, 3], "end": null}), end_name),
(json!({"circle": "C", "end": ""}), end_name),
(json!({"circle": "C", "end": 5}), end_name),
(json!({"circle": [1, 2, 3], "end": null}), end_name),
(json!({"circle": [1, 2, 3], "z": null, "end": 5}), end_name),
(json!({"circle": [1, 2, 3], "z": [0, 0, 0]}), "`z` is a direction [x, y, z]"),
(json!({"circle": [1, 2, 3], "z": "up", "end": "E"}), "`z` is a direction [x, y, z]"),
(json!({"edge": [1, 2, 3], "z": [1, 2]}), "`z` is a direction [x, y, z]"),
(json!({"edge": [1, 2, 3], "z": [0, 0, 0]}), "`z` is a direction [x, y, z]"),
(json!({"edge": [1, 2, 3], "on": 5}), "`on` must be a point [x, y, z]"),
(json!({"edge": [1, 2, 3], "corner": "c"}), "`corner` must be a point [x, y, z]"),
(json!({"point": [1, 2, 3], "z": "up"}), "`z` is a direction [x, y, z]"),
(json!({"point": [1, 2, 3], "x": 5}), "a point origin's `x` is a direction [x, y, z]"),
(json!({"point": [1, 2, 3], "x": "F"}), "a point origin's `x` is a direction [x, y, z]"),
(json!({"point": [1, 2, 3], "name": 5}), "`name` must be text"),
];
for (p, message) in refused {
assert_eq!(origin(p.clone()), [message], "{p}");
}
assert_eq!(origin(json!({"face": "F", "circle": "C", "end": "E"})), [end_round]);
assert_eq!(origin(json!({"point": [1, 2, 3], "nme": "N"})), ["unknown key `nme`; did you mean `name`?"]);
// joint.rigid_group.
let group = |p: Value| issues("joint.rigid_group", p);
let list = "`occurrences` must list 2…1000 occurrences";
assert_eq!(group(json!({"occurrences": [0, "A", 3]})), none());
assert_eq!(group(json!({})), none());
assert_eq!(group(json!({"occurrences": vec![1; 1000]})), none());
for p in [json!(null), json!("A"), json!([]), json!([1]), json!({"0": 1}), json!(vec![1; 1001])] {
assert_eq!(group(json!({ "occurrences": p })), [list], "{p}");
}
assert_eq!(group(json!({"occurrences": [1, true]})), ["an occurrence must be an id or a name"]);
assert_eq!(group(json!({"occurrences": [1, null, 2]})), ["an occurrence must be an id or a name"]);
// joint.reach: a point and a direction as finite numbers under 1e9, `null` refused.
let reach = |extra: Value| {
let mut p = json!({"occurrence": "Arm", "point": [1, 2, 3]});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
issues("joint.reach", p)
};
assert_eq!(reach(json!({})), none());
assert_eq!(
reach(json!({"at": {"x": 1, "y": 2, "z": 3}, "z": [0, 0, 5], "axis": [1, 1, 0], "joints": ["A", 1], "quiet": true, "continuous": false})),
none()
);
assert_eq!(reach(json!({"joints": []})), none());
assert_eq!(reach(json!({"axis": [1, 0, 0]})), none());
assert_eq!(reach(json!({"quiet": null, "continuous": null})), none());
assert_eq!(issues("joint.reach", json!({"point": [1, 2, 3]})), none());
assert_eq!(issues("joint.reach", json!({"occurrence": 2})), none());
assert_eq!(issues("joint.reach", json!({"occurrence": null, "point": [1, 2, 3]})), ["an occurrence must be an id or a name"]);
assert_eq!(issues("joint.reach", json!({"occurrence": 2, "point": null})), ["`point` must be [x, y, z]"]);
for bad in [json!([1, 2]), json!("x"), json!([1e9, 0, 0]), json!({"x": 1, "y": 2}), json!([1, 2, "3"])] {
assert_eq!(reach(json!({"point": bad.clone()})), ["`point` must be [x, y, z]"], "{bad}");
assert_eq!(reach(json!({"at": bad.clone()})), ["`at` must be [x, y, z]"], "{bad}");
}
assert_eq!(reach(json!({"at": null})), ["`at` must be [x, y, z]"]);
for key in ["z", "axis"] {
let message = format!("`{key}` must be a direction [x, y, z]");
for bad in [json!(null), json!([0, 0, 0]), json!([1, 2]), json!("up")] {
assert_eq!(reach(json!({ key: bad.clone() })), [message.as_str()], "{key} {bad}");
}
}
let joints = "`joints` must be a list of joints (ids or names), at most 1000";
for bad in [json!(null), json!("A"), json!(5), json!(vec![1; 1001])] {
assert_eq!(reach(json!({"joints": bad.clone()})), [joints], "{bad}");
}
assert_eq!(reach(json!({"joints": [true]})), ["`joint` must be an id or a name"]);
assert_eq!(reach(json!({"quiet": 1})), ["`quiet` must be true or false"]);
assert_eq!(reach(json!({"continuous": "yes"})), ["`continuous` must be true or false"]);
assert_eq!(reach(json!({"pont": 1})), ["unknown key `pont`; did you mean `point`?"]);
// joint.motion_link.
let link = |extra: Value| {
let mut p = json!({"a": "A", "b": 2, "ratio": 0.5});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
issues("joint.motion_link", p)
};
assert_eq!(link(json!({})), none());
assert_eq!(link(json!({"ia": 1, "ib": -1.5, "offset": "w / 2", "quiet": true})), none());
assert_eq!(link(json!({"ia": null, "ib": null, "quiet": null})), none());
assert_eq!(link(json!({"ratio": -1e8})), none());
assert_eq!(issues("joint.motion_link", json!({})), none());
assert_eq!(issues("joint.motion_link", json!({"a": 1, "b": 2})), none());
for bad in [json!(null), json!("2"), json!(true), json!(1e9), json!(-1e12)] {
assert_eq!(link(json!({"ratio": bad.clone()})), ["`ratio` must be a number"], "{bad}");
}
assert_eq!(link(json!({"a": null})), ["`joint` must be an id or a name"]);
assert_eq!(link(json!({"b": [1]})), ["`joint` must be an id or a name"]);
assert_eq!(link(json!({"ia": "0"})), ["`ia` must be a number"]);
assert_eq!(link(json!({"ib": true})), ["`ib` must be a number"]);
assert_eq!(link(json!({"offset": null})), ["a joint value must be a number or an expression"]);
assert_eq!(link(json!({"offset": true})), ["a joint value must be a number or an expression"]);
assert_eq!(link(json!({"offset": 1e12})), ["a joint value must be a number between -1e9 and 1e9"]);
assert_eq!(link(json!({"quiet": "no"})), ["`quiet` must be true or false"]);
// The couplings: a length, a round face, a link to replace.
let size = "must be a length (mm or an expression) or a round face ({circle: [x, y, z]})";
let gear = |extra: Value| {
let mut p = json!({"a": "A", "b": "B", "ra": 20, "rb": "15 mm"});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
issues("joint.gear", p)
};
assert_eq!(gear(json!({})), none());
assert_eq!(gear(json!({"internal": true, "reverse": false, "link": 0, "quiet": true})), none());
assert_eq!(gear(json!({"internal": null, "reverse": null})), none());
assert_eq!(gear(json!({"ra": {"circle": [10, 0, 2.5]}, "rb": {"circle": "F1", "occurrence": "G2"}})), none());
assert_eq!(gear(json!({"ra": {"circle": [10, 0, 2.5], "z": "junk"}})), none());
assert_eq!(gear(json!({"ra": "x".repeat(1000), "rb": -5})), none());
assert_eq!(issues("joint.gear", json!({"a": 1, "b": 2})), none());
assert_eq!(issues("joint.gear", json!({})), none());
for bad in [json!(null), json!(true), json!([1]), json!({}), json!({"point": [1, 2, 3]}), json!("x".repeat(1001))] {
assert_eq!(gear(json!({"ra": bad.clone()})), [format!("`ra` {size}")], "{bad}");
assert_eq!(gear(json!({"rb": bad.clone()})), [format!("`rb` {size}")], "{bad}");
}
assert_eq!(gear(json!({"ra": {"circle": 5}})), [why]);
assert_eq!(gear(json!({"rb": {"circle": ""}})), ["a face name is 1…16384 characters"]);
assert_eq!(gear(json!({"ra": {"circle": {"x": 1, "y": 2}}})), [why]);
for bad in [json!(-1), json!(null), json!("0"), json!(1.5), json!(true)] {
assert_eq!(gear(json!({"link": bad.clone()})), ["no such motion link"], "{bad}");
}
assert_eq!(gear(json!({"internal": 1})), ["`internal` must be true or false"]);
assert_eq!(gear(json!({"reverse": "yes"})), ["`reverse` must be true or false"]);
assert_eq!(gear(json!({"a": true})), ["`joint` must be an id or a name"]);
assert_eq!(gear(json!({"ra": 1, "a": null, "link": -1})), ["`joint` must be an id or a name"]);
let rack = |extra: Value| {
let mut p = json!({"a": "P", "b": "Rack", "radius": "r"});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
issues("joint.rack_pinion", p)
};
assert_eq!(rack(json!({})), none());
assert_eq!(rack(json!({"radius": {"circle": [0, 0, 0]}, "reverse": true, "link": 3, "quiet": false})), none());
assert_eq!(rack(json!({"radius": null})), [format!("`radius` {size}")]);
assert_eq!(rack(json!({"link": 2.5})), ["no such motion link"]);
assert_eq!(issues("joint.rack_pinion", json!({"a": "P", "b": "Rack"})), none());
// joint.screw: `joint`, once given, is the form, and `a` and `b` are not read.
let screw = |p: Value| issues("joint.screw", p);
assert_eq!(screw(json!({"joint": "Nut", "pitch": 2})), none());
assert_eq!(screw(json!({"joint": 3, "a": true, "b": null, "pitch": "2 mm", "reverse": true, "link": 1, "quiet": true})), none());
assert_eq!(screw(json!({"a": "K", "b": 4, "pitch": {"circle": [1, 2, 3]}})), none());
assert_eq!(screw(json!({})), none());
assert_eq!(screw(json!({"joint": null, "pitch": 1})), ["`joint` must be an id or a name"]);
assert_eq!(screw(json!({"joint": true, "a": "K", "b": 4, "pitch": 1})), ["`joint` must be an id or a name"]);
assert_eq!(screw(json!({"a": true, "b": 4, "pitch": 1})), ["`joint` must be an id or a name"]);
assert_eq!(screw(json!({"a": "K", "b": null, "pitch": 1})), ["`joint` must be an id or a name"]);
assert_eq!(screw(json!({"joint": "Nut", "pitch": false})), [format!("`pitch` {size}")]);
assert_eq!(screw(json!({"joint": "Nut", "pitch": 1, "link": "0"})), ["no such motion link"]);
// joint.cam: the follower, its radius only for a roller, the tip, the profile.
let cam = |extra: Value| {
let mut p = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "S"}});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
issues("joint.cam", p)
};
assert_eq!(cam(json!({})), none());
assert_eq!(cam(json!({"follower": "knife", "radius": true, "flip": true, "link": 0, "quiet": true})), none());
assert_eq!(cam(json!({"follower": "flat", "radius": null})), none());
assert_eq!(cam(json!({"follower": null, "radius": []})), none());
assert_eq!(cam(json!({"follower": "roller", "radius": "5 mm", "tip": {"circle": [1, 2, 3]}})), none());
assert_eq!(cam(json!({"follower": "roller", "radius": {"circle": [1, 2, 3]}})), none());
assert_eq!(cam(json!({"tip": "Origin1"})), none());
assert_eq!(cam(json!({"tip": {"point": [1, 2, 3]}})), none());
assert_eq!(cam(json!({"flip": null})), none());
assert_eq!(issues("joint.cam", json!({"a": 1, "b": 2})), none());
assert_eq!(issues("joint.cam", json!({})), none());
assert_eq!(cam(json!({"follower": "round"})), ["`follower` is knife, roller or flat"]);
assert_eq!(cam(json!({"follower": "Roller"})), ["`follower` is knife, roller or flat"]);
assert_eq!(cam(json!({"follower": 5})), ["`follower` must be text"]);
assert_eq!(cam(json!({"follower": "roller"})), none());
assert_eq!(cam(json!({"follower": "roller", "radius": true})), [format!("`radius` {size}")]);
assert_eq!(cam(json!({"follower": "roller", "radius": null})), [format!("`radius` {size}")]);
assert_eq!(cam(json!({"tip": null})), [why]);
assert_eq!(cam(json!({"tip": 5})), [why]);
assert_eq!(cam(json!({"tip": {}})), [why]);
assert_eq!(cam(json!({"tip": {"face": ""}})), ["a face name is 1…16384 characters"]);
assert_eq!(cam(json!({"flip": "yes"})), ["`flip` must be true or false"]);
assert_eq!(cam(json!({"link": null})), ["no such motion link"]);
for ok in [
json!({"sketch": "S"}),
json!({"sketch": 4, "index": 2}),
json!({"sketch": "S", "edges": [], "occurrence": true}),
json!({"sketch": "S", "index": 0, "x": 1}),
json!({"edges": [[1, 2, 3]]}),
json!({"edges": [{"x": 1, "y": 2, "z": 3}], "occurrence": "Cam", "index": "x"}),
json!({"edges": vec![[1, 2, 3]; 64]}),
] {
assert_eq!(cam(json!({"profile": ok.clone()})), none(), "{ok}");
}
let forms = "`profile` is {sketch: id|name} or {edges: [[x, y, z], …]}";
for (bad, message) in [
(json!(null), forms),
(json!("S"), forms),
(json!([1]), forms),
(json!({}), forms),
(json!({"edges": "x"}), forms),
(json!({"sketch": null}), "`profile.sketch` must be a sketch id or name"),
(json!({"sketch": [1]}), "`profile.sketch` must be a sketch id or name"),
(json!({"sketch": "S", "index": -1}), "the sketch has no such closed profile (an open curve makes none)"),
(json!({"sketch": "S", "index": "0"}), "the sketch has no such closed profile (an open curve makes none)"),
(json!({"sketch": "S", "index": null}), "the sketch has no such closed profile (an open curve makes none)"),
(json!({"edges": []}), "pick 1 to 64 edges"),
(json!({"edges": vec![[1, 2, 3]; 65]}), "pick 1 to 64 edges"),
(json!({"edges": [[1, 2]]}), "`edges` are finite [x, y, z] points"),
(json!({"edges": [[1, 2, 3], "x"]}), "`edges` are finite [x, y, z] points"),
(json!({"edges": [[1, 2, 3]], "occurrence": null}), "an occurrence must be an id or a name"),
(json!({"edges": [[1, 2, 3]], "occurrence": {}}), "an occurrence must be an id or a name"),
] {
assert_eq!(cam(json!({"profile": bad.clone()})), [message], "{bad}");
}
assert_eq!(cam(json!({"profle": 1})), ["unknown key `profle`; did you mean `profile`?"]);
}
/// The schemas say what the handlers need, and take what they read.
#[test]
fn the_remaining_schemas_take_what_the_handlers_read() {
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
let schema = schema.as_value();
for (p, takes) in cases {
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
}
};
takes(
input_schema_for::<MakeJointOriginParams>(),
&[
(json!({"face": [1, 2, 3]}), true),
(json!({"face": "F1", "x": "F2", "name": "Top", "body": "B", "occurrence": "A"}), true),
(json!({"circle": "F1", "end": "F2", "x": {"face": [1, 2, 3]}}), true),
(json!({"circle": [1, 2, 3], "z": [0, 0, 1], "x": null}), true),
(json!({"edge": [1, 2, 3], "on": [0, 0, 0], "corner": {"x": 1, "y": 2, "z": 3}, "z": [1, 0, 0]}), true),
(json!({"point": [1, 2, 3], "z": [0, 0, 1], "x": [1, 0, 0]}), true),
(json!({"frame": {"origin": [0, 0, 0], "z": [0, 0, 1], "x": [1, 0, 0]}}), true),
(json!({}), false),
(json!({"name": "N"}), false),
(json!({"face": [1, 2]}), false),
(json!({"point": [1, 2, 3], "end": 5}), false),
(json!({"frame": {"origin": [0, 0, 0]}}), false),
(json!({"point": [1, 2, 3], "x": 5}), false),
(json!({"point": [1, 2, 3], "nme": "N"}), false),
],
);
takes(
input_schema_for::<RigidGroupParams>(),
&[
(json!({"occurrences": ["A", 2]}), true),
(json!({"occurrences": ["A"]}), false),
(json!({"occurrences": vec![1; 1001]}), false),
(json!({"occurrences": [1, true]}), false),
(json!({}), false),
],
);
takes(
input_schema_for::<ReachParams>(),
&[
(json!({"occurrence": "Arm", "point": [1, 2, 3]}), true),
(
json!({"occurrence": 2, "point": {"x": 1, "y": 2, "z": 3}, "at": [0, 0, 0], "z": [0, 0, 1], "axis": [1, 0, 0], "joints": ["A", 3], "quiet": true, "continuous": true}),
true,
),
(json!({"point": [1, 2, 3]}), false),
(json!({"occurrence": "Arm"}), false),
(json!({"occurrence": "Arm", "point": [1, 2]}), false),
(json!({"occurrence": "Arm", "point": [1, 2, 3], "joints": "A"}), false),
(json!({"occurrence": "Arm", "point": [1, 2, 3], "at": null}), false),
],
);
takes(
input_schema_for::<MotionLinkParams>(),
&[
(json!({"a": "A", "b": 2, "ratio": 0.5}), true),
(json!({"a": "A", "b": 2, "ratio": -2, "ia": 1, "ib": 0, "offset": "5 mm", "quiet": true}), true),
(json!({"a": "A", "b": 2}), false),
(json!({"a": "A", "ratio": 1}), false),
(json!({"a": "A", "b": 2, "ratio": "2"}), false),
(json!({"a": "A", "b": 2, "ratio": 1e12}), false),
(json!({"a": "A", "b": 2, "ratio": 1, "offset": true}), false),
],
);
let gear = json!({"a": "A", "b": "B", "ra": 20, "rb": {"circle": [1, 2, 3], "occurrence": "G"}});
takes(
input_schema_for::<GearParams>(),
&[
(gear.clone(), true),
(json!({"a": 1, "b": 2, "ra": "r1", "rb": "15 mm", "internal": true, "reverse": true, "link": 0, "quiet": false}), true),
(json!({"a": "A", "b": "B", "ra": 20}), false),
(json!({"a": "A", "b": "B", "ra": 20, "rb": true}), false),
(json!({"a": "A", "b": "B", "ra": 20, "rb": {"point": [1, 2, 3]}}), false),
(json!({"a": "A", "b": "B", "ra": 20, "rb": 1, "link": -1}), false),
(json!({"a": "A", "b": "B", "ra": 20, "rb": 1, "link": "0"}), false),
],
);
takes(
input_schema_for::<RackPinionParams>(),
&[
(json!({"a": "P", "b": "Rack", "radius": 10}), true),
(json!({"a": "P", "b": "Rack", "radius": "r", "reverse": true, "link": 2}), true),
(json!({"a": "P", "b": "Rack"}), false),
(json!({"a": "P", "radius": 10}), false),
],
);
takes(
input_schema_for::<ScrewParams>(),
&[
(json!({"joint": "Nut", "pitch": 2}), true),
(json!({"a": "K", "b": "S", "pitch": "2 mm", "reverse": true, "link": 1}), true),
(json!({"joint": "Nut"}), false),
(json!({"pitch": 2}), false),
(json!({"a": "K", "pitch": 2}), false),
(json!({"joint": "Nut", "pitch": 2, "link": -1}), false),
],
);
takes(
input_schema_for::<CamParams>(),
&[
(json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "S"}}), true),
(
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": 3, "index": 1}, "follower": "flat", "flip": true, "link": 0, "quiet": true}),
true,
),
(
json!({"a": "Cam", "b": "Arm", "profile": {"edges": [[1, 2, 3]], "occurrence": "Cam"}, "follower": "roller", "radius": 5, "tip": {"circle": [1, 2, 3]}}),
true,
),
(json!({"a": "Cam", "b": "Tappet", "profile": {"edges": []}}), false),
(json!({"a": "Cam", "b": "Tappet", "profile": {"edges": vec![[1, 2, 3]; 65]}}), false),
(json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "S", "index": -1}}), false),
(json!({"a": "Cam", "b": "Tappet", "profile": {}}), false),
(json!({"a": "Cam", "b": "Tappet"}), false),
(json!({"a": "Cam", "profile": {"sketch": "S"}}), false),
],
);
// A roller needs its radius, a screw its joint or its two, a reach its axis's z, a joint
// origin one of its forms: said in the schema (`conforms` doesn't read these).
let cam = input_schema_for::<CamParams>();
assert_eq!(cam.as_value()["if"], json!({"properties": {"follower": {"const": "roller"}}, "required": ["follower"]}));
assert_eq!(cam.as_value()["then"], json!({"required": ["radius"]}));
let screw = input_schema_for::<ScrewParams>();
assert_eq!(screw.as_value()["anyOf"], json!([{"required": ["joint"]}, {"required": ["a", "b"]}]));
assert_eq!(input_schema_for::<ReachParams>().as_value()["dependentRequired"], json!({"axis": ["z"]}));
assert_eq!(input_schema_for::<MakeJointOriginParams>().as_value()["anyOf"].as_array().map(Vec::len), Some(5));
}
/// A design with the pieces the couplings join, and a tray with a hole for the joint origins by
/// name: each command run in Warn in every form it takes gives a reply that matches its output
/// schema, with no warning.
#[test]
fn replies_of_the_rest_match_the_output_schema() {
let ok = |schema: schemars::Schema, id: &str, reply: &Value| matches(schema, id, reply);
// joint.origin, in the forms of a face, a circle, an edge, a point and a frame.
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
put(&mut s, "component.create", json!({"name": "Tray"}));
put(&mut s, "solid.box", json!({"length": 300, "width": 100, "height": 1, "corner": [0, 0, 0]}));
put(&mut s, "solid.hole", json!({"position": [280, 50, 1], "diameter": 6}));
let name_at = |s: &mut Session, p: Vec3, circle: bool| {
let st = s.model.state();
let c = s.doc.find_component("Tray").unwrap_or(0);
solidcraft_doc::joints::face_name_at(&s.doc, &st, c, p, circle).unwrap_or_default()
};
let wall = name_at(&mut s, Vec3::new(283.0, 50.0, 0.5), false);
let top = name_at(&mut s, Vec3::new(150.0, 50.0, 1.0), false);
let right = name_at(&mut s, Vec3::new(300.0, 50.0, 0.5), false);
assert!(![&wall, &top, &right].iter().any(|n| n.is_empty()));
let schema = || output_schema_for::<MakeJointOriginResult>();
let mut made = Vec::new();
for p in [
json!({"occurrence": "Tray", "face": [150, 50, 1], "name": "TopPoint"}),
json!({"face": [150, 50, 1], "x": [0, 1, 0]}),
json!({"face": top.clone(), "name": "TopName", "x": right.clone()}),
json!({"face": top.clone(), "occurrence": "Tray", "body": null, "x": {"face": [300, 50, 0.5]}, "name": " Padded "}),
json!({"circle": wall.clone(), "end": top.clone(), "x": right.clone(), "name": "Hole"}),
json!({"circle": [283, 50, 0.5], "name": "HoleAt"}),
json!({"circle": [283, 50, 0.5], "z": [0, 0, 1], "name": "HoleOut", "end": "not checked here"}),
json!({"circle": [283, 50, 0.5], "end": top.clone(), "x": [1, 0, 0], "name": "HoleEnd"}),
json!({"edge": [150, 0, 1], "name": "Edge"}),
json!({"edge": [150, 0, 1], "on": [150, 50, 1], "corner": [299, 0, 1], "name": "Corner"}),
json!({"edge": [150, 0, 1], "z": [1, 0, 0], "end": 5, "name": "EdgeZ"}),
json!({"point": [10, 10, 1], "z": [0, 0, 1], "x": [1, 0, 0], "name": "Point"}),
json!({"point": [10, 10, 1], "occurrence": "Tray", "name": null}),
json!({"frame": {"origin": [5, 5, 1], "z": [0, 0, 1], "x": [1, 0, 0]}, "occurrence": "Tray", "name": "Frame"}),
json!({"frame": {"origin": [5, 5, 1], "z": [0, 0, 1]}, "occurrence": 0}),
] {
let r = put(&mut s, "joint.origin", p.clone());
ok(schema(), "joint.origin", &r);
made.push(r["origin"].as_str().unwrap_or_default().to_string());
}
assert_eq!(made[0], "TopPoint");
assert_eq!(made[1], "Joint Origin1");
assert_eq!(made[3], "Padded");
assert_eq!(made[12], "Joint Origin2");
assert_eq!(s.doc.assembly.origins.len(), made.len());
// joint.rigid_group, by name and by id.
let mut g = Session { param_check: ParamCheck::Warn, ..Session::default() };
let mut ids = Vec::new();
for (name, x) in [("A", 0), ("B", 20), ("C", 40), ("D", 60)] {
put(&mut g, "component.activate", json!({"component": "root"}));
let made = put(&mut g, "component.create", json!({"name": name, "grounded": false}));
put(&mut g, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
ids.push(made["occurrence"].clone());
}
let group = put(&mut g, "joint.rigid_group", json!({"occurrences": ["A", "B", "C"]}));
ok(output_schema_for::<RigidGroupResult>(), "joint.rigid_group", &group);
assert!(group["joints"].as_array().is_some_and(|j| j.len() == 2 && j.iter().all(Value::is_u64)), "{group}");
let by_id = put(&mut g, "joint.rigid_group", json!({"occurrences": [ids[3].clone(), 0]}));
ok(output_schema_for::<RigidGroupResult>(), "joint.rigid_group", &by_id);
assert!(by_id["joints"].as_array().is_some_and(|j| j.len() == 1), "{by_id}");
// The couplings and motion links.
let mut s = rig(ParamCheck::Warn);
let coupled = |s: &mut Session, id: &str, p: Value| {
let r = put(s, id, p.clone());
ok(output_schema_for::<CouplingResult>(), id, &r);
let quiet = p["quiet"] == json!(true);
assert!(r["link"].is_u64() && r["ratio"].is_number() && r.get("dof").is_some() != quiet, "{id} {p}: {r}");
r
};
let gear = coupled(&mut s, "joint.gear", json!({"a": "G1", "b": "G2", "ra": 10, "rb": "20 mm"}));
assert!(gear["link"] == 0 && gear["ratio"].as_f64().is_some_and(|r| (r + 0.5).abs() < 1e-9), "{gear}");
let again = coupled(
&mut s,
"joint.gear",
json!({"a": "G1", "b": "G2", "ra": {"occurrence": "G1", "circle": [10, 0, 2.5]}, "rb": {"occurrence": "G2", "circle": [50, 0, 2.5]},
"internal": true, "reverse": true, "link": 0}),
);
assert_eq!(again["link"], 0, "{again}");
let quiet = coupled(&mut s, "joint.gear", json!({"a": "G1", "b": "G2", "ra": 10, "rb": 20, "link": 0, "quiet": true, "internal": null}));
assert!(quiet.get("dof").is_none() && quiet.get("fixed").is_none(), "{quiet}");
let rack = coupled(&mut s, "joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": 10}));
assert_eq!(rack["link"], 1, "{rack}");
coupled(&mut s, "joint.rack_pinion", json!({"a": "Rack", "b": "P", "radius": "10 mm", "reverse": true, "link": 1}));
let screw = coupled(&mut s, "joint.screw", json!({"joint": "Nut", "pitch": 2}));
assert_eq!(screw["link"], 2, "{screw}");
coupled(&mut s, "joint.screw", json!({"joint": "Nut", "pitch": "2 mm", "reverse": true, "link": 2, "a": true}));
let apart = coupled(&mut s, "joint.screw", json!({"a": "K", "b": "Slide2", "pitch": 3}));
assert_eq!(apart["link"], 3, "{apart}");
let linked = put(&mut s, "joint.motion_link", json!({"a": "G1", "b": "X", "ratio": 2}));
ok(output_schema_for::<SolveResult>(), "joint.motion_link", &linked);
let quiet = put(&mut s, "joint.motion_link", json!({"a": "G1", "b": "K", "ratio": 1, "offset": "5 deg", "ia": 0, "ib": 0, "quiet": true}));
ok(output_schema_for::<SolveResult>(), "joint.motion_link", &quiet);
assert!(quiet.get("dof").is_none() && quiet.get("fixed").is_none(), "{quiet}");
let list = put(&mut s, "joint.list", json!({}));
assert!(list["links"].as_array().is_some_and(|l| l.len() == 6), "{list}");
// joint.reach: an arm.
let mut a = Session { param_check: ParamCheck::Warn, ..Session::default() };
put(&mut a, "component.activate", json!({"component": "root"}));
let base = put(&mut a, "component.create", json!({"name": "Base", "grounded": false}));
put(&mut a, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [-1, -1, -1]}));
put(&mut a, "occurrence.ground", json!({"occurrence": base["occurrence"], "grounded": true}));
for (name, x) in [("Upper", 30), ("Fore", 80)] {
put(&mut a, "component.activate", json!({"component": "root"}));
put(&mut a, "component.create", json!({"name": name, "grounded": false}));
put(&mut a, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [x, -1, -1]}));
}
pin(&mut a, "revolute", "Shoulder", "Base", "Upper", [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
pin(&mut a, "revolute", "Elbow", "Upper", "Fore", [60.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
put(&mut a, "joint.drive", json!({"joint": "Elbow", "value": 30}));
let reach = |a: &mut Session, p: Value| {
let r = put(a, "joint.reach", p.clone());
ok(output_schema_for::<ReachResult>(), "joint.reach", &r);
r
};
// Fore's tip, with the elbow at 30°.
let tip = json!([60.0 + 40.0 * 30f64.to_radians().cos(), 20.0, 0.0]);
let moved = reach(&mut a, json!({"occurrence": "Fore", "at": tip, "point": [50, 50, 0]}));
assert!(moved["reached"] == json!(true) && moved["moved"] == json!(true) && moved["occurrence"] == "Fore:1", "{moved}");
assert!(moved["stopped_by_limit"].is_null() && moved["stopped_by_loop"].is_null() && moved.get("error_deg").is_none(), "{moved}");
assert!(moved["values"].as_array().is_some_and(|v| v.len() == 2 && v[0]["values"][0]["unit"] == "deg"), "{moved}");
let default_at =
reach(&mut a, json!({"occurrence": 2, "point": {"x": 40, "y": 30, "z": 0}, "quiet": true, "continuous": true, "joints": ["Shoulder"]}));
assert!(default_at["continuous"] == json!(true) && default_at.get("dof").is_none(), "{default_at}");
let turned = reach(&mut a, json!({"occurrence": "Fore", "point": [30, 70, 0], "z": [0, 1, 0], "axis": [1, 0, 0]}));
assert!(turned["error_deg"].is_number(), "{turned}");
let turned = reach(&mut a, json!({"occurrence": "Fore", "point": [0, 80, 0], "z": [0, 1, 0]}));
assert!(turned["error_deg"].is_number() && turned["reached"].is_boolean(), "{turned}");
put(&mut a, "joint.limits", json!({"joint": "Elbow", "min": 20, "max": 40}));
let limited = reach(&mut a, json!({"occurrence": "Fore", "point": [-150, 0, 0]}));
assert!(limited["stopped_by_limit"].as_array().is_some_and(|l| !l.is_empty() && l[0]["limit"] == "min"), "{limited}");
// A four-bar's rocker pulled past its toggle position: the loop that stops it is named.
let mut f = Session { param_check: ParamCheck::Warn, ..Session::default() };
let (o4, p0) = ((45.0f64, 0.0f64), (0.0f64, 20.0f64));
let (dx, dy) = (o4.0 - p0.0, o4.1 - p0.1);
let l = dx.hypot(dy);
let along = (50.0f64.powi(2) - 40.0f64.powi(2) + l * l) / (2.0 * l);
let off = (50.0f64.powi(2) - along * along).sqrt();
let q0 = (p0.0 + dx * along / l - dy * off / l, p0.1 + dy * along / l + dx * off / l);
for (name, c) in [("Ground", (0.0, 0.0)), ("Crank", p0), ("Coupler", ((p0.0 + q0.0) / 2.0, (p0.1 + q0.1) / 2.0)), ("Rocker", q0)] {
put(&mut f, "component.activate", json!({"component": "root"}));
let made = put(&mut f, "component.create", json!({"name": name, "grounded": false}));
put(&mut f, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [c.0, c.1, -5]}));
if name == "Ground" {
put(&mut f, "occurrence.ground", json!({"occurrence": made["occurrence"], "grounded": true}));
}
}
let z = [0.0, 0.0, 1.0];
pin(&mut f, "revolute", "Crank", "Ground", "Crank", [0.0, 0.0, 0.0], z);
pin(&mut f, "revolute", "Crank pin", "Crank", "Coupler", [p0.0, p0.1, 0.0], z);
pin(&mut f, "revolute", "Rocker pin", "Coupler", "Rocker", [q0.0, q0.1, 0.0], z);
pin(&mut f, "revolute", "Rocker", "Ground", "Rocker", [o4.0, o4.1, 0.0], z);
let start = (q0.1 - o4.1).atan2(q0.0 - o4.0) + 100f64.to_radians();
let past = [o4.0 + 40.0 * start.cos(), o4.1 + 40.0 * start.sin(), 0.0];
let stuck = reach(&mut f, json!({"occurrence": "Rocker", "at": [q0.0, q0.1, 0.0], "point": past}));
assert_eq!(stuck["stopped_by_loop"], json!({"joint": "Rocker pin"}), "{stuck}");
let fixed = reach(&mut a, json!({"occurrence": "Base", "point": [50, 50, 0]}));
assert!(fixed["reason"].is_string() && fixed["reached"] == json!(false) && fixed["values"] == json!([]), "{fixed}");
}
/// A cam in every form: a sketch's profile or picked edges, a knife, a roller and a flat face, a
/// translating and an oscillating follower, a link replaced. The replies match the output schema.
#[test]
fn cam_replies_match_the_output_schema() {
let mut s = cam_rig(ParamCheck::Warn);
let cam = |s: &mut Session, p: Value| {
let r = put(s, "joint.cam", p.clone());
matches(output_schema_for::<CamResult>(), "joint.cam", &r);
assert!(r["link"].is_u64() && r["lift"]["unit"].is_string() && r["reverse"].is_boolean() && r.get("ratio").is_none(), "{p}: {r}");
r
};
let knife = cam(&mut s, json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}}));
assert!(knife["lift"]["unit"] == "mm" && knife["follower_offset"].is_number(), "{knife}");
let sized = cam(
&mut s,
json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch", "index": 0}, "follower": "roller", "radius": "5 mm", "link": 0, "quiet": true}),
);
assert!(sized.get("dof").is_none(), "{sized}");
cam(
&mut s,
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[10, 30, 5]], "occurrence": "Cam"}, "follower": "flat", "flip": true, "link": 0}),
);
cam(
&mut s,
json!({"a": "Cam", "b": "Tappet", "profile": {"edges": [[10, 30, 5]]}, "follower": "knife", "radius": true, "tip": {"occurrence": "Tappet", "point": [45, 0, 2.5]}, "link": 0}),
);
// An oscillating follower: its tip, no offset in the reply.
// The roller's centre is where the arm's circle (50 about the pivot) meets the circle of
// radius 30 + 5 about the cam's centre.
let (pivot, centre) = ((-50.0f64, 40.0f64), (10.0f64, 0.0f64));
let (dx, dy) = (centre.0 - pivot.0, centre.1 - pivot.1);
let l = dx.hypot(dy);
let a = (50.0f64.powi(2) - 35.0f64.powi(2) + l * l) / (2.0 * l);
let h = (50.0f64.powi(2) - a * a).sqrt();
let start = [pivot.0 + dx * a / l - dy * h / l, pivot.1 + dy * a / l + dx * h / l, 2.5];
let swing = cam(
&mut s,
json!({"a": "Cam", "b": "Arm", "profile": {"sketch": "CamSketch"}, "follower": "roller", "radius": 5, "tip": {"occurrence": "Arm", "point": start}}),
);
assert!(swing["lift"]["unit"] == "deg" && swing.get("follower_offset").is_none(), "{swing}");
}
/// A misspelt key still runs in Warn, says so, and the reply still matches its schema.
#[test]
fn a_misspelt_key_is_reported_in_warn_for_the_rest() {
let mut s = rig(ParamCheck::Warn);
let calls = [
("joint.origin", json!({"occurrence": "Base", "point": [0, 0, 0], "nme": "A"}), "name"),
("joint.rigid_group", json!({"occurrences": ["G1", "G2"], "occurences": 1}), "occurrences"),
("joint.reach", json!({"occurrence": "G2", "point": [30, 20, 0], "quiet": true, "quet": true}), "quiet"),
("joint.motion_link", json!({"a": "P", "b": "X", "ratio": 1, "ratjo": 1}), "ratio"),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 10, "rb": 20, "internl": true}), "internal"),
("joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": 10, "reverce": true}), "reverse"),
("joint.screw", json!({"joint": "Nut", "pitch": 2, "pich": 2}), "pitch"),
];
for (id, p, near) in calls {
let reply = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
let warnings = reply["param_warnings"].as_array().cloned().unwrap_or_default();
assert!(warnings.len() == 1 && warnings[0].as_str().is_some_and(|w| w.contains("unknown key") && w.contains(near)), "{id}: {reply}");
let out = crate::cmd::find_command(id).and_then(|c| c.output).map(|f| f()).unwrap_or_else(|| panic!("{id}"));
assert!(conforms(out.as_value(), out.as_value(), &reply), "{id}: {reply} doesn't match {}", out.as_value());
}
let mut c = cam_rig(ParamCheck::Warn);
let p = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follwer": "flat"});
let reply = put(&mut c, "joint.cam", p);
assert!(reply["param_warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("follower"))), "{reply}");
let out = output_schema_for::<CamResult>();
assert!(conforms(out.as_value(), out.as_value(), &reply), "{reply}");
}
/// A wrong-typed key the handlers read as if it were left out is refused in strict (the stricter
/// reading), and the call works with the check off.
#[test]
fn strict_refuses_wrong_types_the_handlers_ignore_in_the_rest() {
let cases = [
("joint.origin", json!({"occurrence": "Base", "point": [0, 0, 0], "name": 5})),
("joint.origin", json!({"occurrence": "Base", "point": [0, 0, 0], "z": "up"})),
("joint.reach", json!({"occurrence": "G2", "point": [30, 20, 0], "quiet": "yes"})),
("joint.motion_link", json!({"a": "G1", "b": "X", "ratio": 1, "ia": "0"})),
("joint.gear", json!({"a": "G1", "b": "G2", "ra": 10, "rb": 20, "reverse": "no"})),
("joint.rack_pinion", json!({"a": "P", "b": "Rack", "radius": 10, "quiet": 1})),
("joint.screw", json!({"joint": "Nut", "pitch": 2, "reverse": "x"})),
];
for (id, p) in cases {
let works = |level| rig(level).execute(id, &p).is_ok();
assert!(works(ParamCheck::Off) && !works(ParamCheck::Strict), "{id} {p}");
}
let cam = json!({"a": "Cam", "b": "Tappet", "profile": {"sketch": "CamSketch"}, "follower": 5});
let works = |level| cam_rig(level).execute("joint.cam", &cam).is_ok();
assert!(works(ParamCheck::Off) && !works(ParamCheck::Strict), "{cam}");
}
+3
View File
@@ -101,6 +101,7 @@ pub mod render_stage;
pub mod render_surface_resources;
pub mod render_ui;
pub mod section;
pub mod select;
pub mod sheet;
pub mod silhouette;
pub mod sketch;
@@ -110,9 +111,11 @@ pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_ends;
pub mod sketch_equation;
pub mod sketch_import;
pub mod sketch_inspect;
pub mod sketch_modify;
pub mod sketch_project;
pub mod sketch_session;
pub mod sketch_shapes;
pub mod surface_analysis;
pub mod surface_boundary;
+151
View File
@@ -0,0 +1,151 @@
//! Contracts and result types for the selection commands in `cmd/edit.rs`: `select.set` and
//! `select.clear`.
//!
//! `select.set` reads `items` and `add` from the raw JSON. Each item reads as the handler reads
//! it ([`read_item`] is the handler's own first step, so a call the handler takes is a call the
//! contract takes): a face or edge with an `index` or a `point` (not both) is read as a partial
//! pick, which the handler completes against the design, and anything else is read as a
//! [`Sel`]. Whether a body, face or edge is there, and how many items are too many, stay the
//! handler's to check. A call with several problems may get a different one first in strict than
//! with the check off (the check goes key by key, the handler item by item against the design).
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use solidcore_record::Vec3;
use super::check::whole;
use crate::Sel;
/// A face or edge picked by an index or a point only, which the handler completes.
#[derive(Debug, Deserialize)]
pub struct PartialPick {
pub body: String,
pub index: Option<usize>,
pub point: Option<Vec3>,
}
/// One item of `select.set`, as the handler first reads it.
#[derive(Debug)]
pub enum SelectItem {
/// A complete selection item.
Exact(Sel),
/// A face (`face` true) or edge given by its body and one of an index or a point.
Pick { face: bool, pick: PartialPick },
}
/// Read one item of `select.set`; the error is the handler's message. A face or edge that has
/// both `index` and `point` (even `null`) is read as a [`Sel`], so is any other type; a face or
/// edge with at most one of them is read as a [`PartialPick`], which reads only `body`, `index`
/// and `point`.
pub(crate) fn read_item(value: Value) -> Result<SelectItem, String> {
let kind = value.get("type").and_then(Value::as_str);
if !matches!(kind, Some("face" | "edge")) || (value.get("index").is_some() && value.get("point").is_some()) {
return serde_json::from_value(value).map(SelectItem::Exact).map_err(|e| e.to_string());
}
let face = kind == Some("face");
serde_json::from_value(value).map(|pick| SelectItem::Pick { face, pick }).map_err(|e| e.to_string())
}
impl<'de> Deserialize<'de> for SelectItem {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_item(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(e)))
}
}
/// A point in world mm.
fn point() -> Value {
serde_json::json!({"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3})
}
impl JsonSchema for SelectItem {
fn schema_name() -> Cow<'static, str> {
"SelectItem".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
let text = || serde_json::json!({"type": "string"});
let count = || serde_json::json!({"type": "integer", "minimum": 0});
let variant = |kind: &str, what: &str, properties: Value, required: &[&str]| {
let required: Vec<&str> = std::iter::once("type").chain(required.iter().copied()).collect();
let mut all = serde_json::json!({"type": {"const": kind}});
if let (Some(all), Some(properties)) = (all.as_object_mut(), properties.as_object()) {
all.extend(properties.clone());
}
serde_json::json!({"description": what, "type": "object", "properties": all, "required": required})
};
let pick = |kind: &str, what: &str| {
let mut v = variant(kind, what, serde_json::json!({"body": text(), "index": count(), "point": point()}), &["body"]);
v["anyOf"] = serde_json::json!([{"required": ["index"]}, {"required": ["point"]}]);
v
};
json_schema!({
"description": "What to select, by `type`. Keys a type doesn't read are ignored. A face or edge needs `body` and an `index` or a `point` (both are accepted); the engine finds the other.",
"anyOf": [
variant("body", "A body, by name.", serde_json::json!({"name": text()}), &["name"]),
pick("face", "A face of a body, by index or by a point on it (world mm, within Measure's pick tolerance)."),
pick("edge", "An edge of a body, by index or by a point on it (world mm, within Measure's pick tolerance)."),
variant("vertex", "A vertex of a body, at a point.", serde_json::json!({"body": text(), "point": point()}), &["body", "point"]),
variant("occurrence", "A placed component, by its path of occurrence ids.", serde_json::json!({"path": {"type": "array", "items": count()}}), &["path"]),
variant("feature", "A timeline feature, by id.", serde_json::json!({"id": count()}), &["id"]),
variant("sketch_curve", "A curve of the active sketch, by id; `sketch` names the sketch it is in.", serde_json::json!({"id": text(), "sketch": {"type": ["integer", "null"], "minimum": 0}}), &["id"]),
variant("sketch_point", "A point of the active sketch, by id.", serde_json::json!({"id": text()}), &["id"]),
variant("sketch_midpoint", "The midpoint of a line of the active sketch, by id.", serde_json::json!({"id": text()}), &["id"]),
variant("external_sketch_curve", "A curve of another sketch.", serde_json::json!({"sketch": count(), "id": text()}), &["sketch", "id"]),
variant("external_sketch_point", "A point of another sketch.", serde_json::json!({"sketch": count(), "id": text()}), &["sketch", "id"]),
variant("sketch_constraint", "A constraint or dimension of the active sketch, by id.", serde_json::json!({"id": text()}), &["id"]),
variant("profile", "A closed profile of a sketch, by sketch id and index.", serde_json::json!({"sketch": count(), "index": count()}), &["sketch", "index"]),
variant("plane", "An origin plane (XY, XZ, YZ) or a construction plane, by name.", serde_json::json!({"name": text()}), &["name"]),
variant("construction_point", "A construction point, by feature name.", serde_json::json!({"name": text()}), &["name"]),
variant("axis", "An origin axis (X, Y, Z) or a construction axis, by name.", serde_json::json!({"name": text()}), &["name"])
]
})
}
}
/// Make what is selected the given items, or add them to it. Nothing changes when an item is
/// refused. A body, face or edge must exist in the design; a body or occurrence that isn't there
/// is refused. At most 10,000 items. Not an undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SelectSetParams {
/// What to select. Default: none (the selection is cleared, or left as it is with `add`).
/// A face or edge may give just an `index` or just a `point`; the engine fills in the other.
#[serde(default)]
pub items: Option<Vec<SelectItem>>,
/// Add to the current selection instead of replacing it. Items already selected are not
/// added twice. Default: false.
#[serde(default)]
pub add: Option<bool>,
}
/// What `select.set` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SelectSetResult {
/// How many items are selected now.
pub selected: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// `select.clear` takes no parameters: it deselects everything. Not an undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SelectClearParams {}
/// What `select.clear` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SelectClearResult {
/// How many items are selected now: always 0.
pub selected: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "select_tests.rs"]
mod tests;
+223
View File
@@ -0,0 +1,223 @@
use serde_json::{Value, json};
use super::{SelectClearParams, SelectClearResult, SelectItem, SelectSetParams, SelectSetResult, read_item};
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
use crate::{Sel, Session};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SelectSetParams>(), &["add", "items"]);
assert_keys(input_schema_for::<SelectClearParams>(), &[]);
}
/// Strict gives the handler's message for the items it refuses on their own.
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("select.set", json!({"items": [{"type": "bogus"}]})),
("select.set", json!({"items": [{}]})),
("select.set", json!({"items": [7]})),
("select.set", json!({"items": [{"type": "body"}]})),
("select.set", json!({"items": [{"type": "body", "name": 5}]})),
("select.set", json!({"items": [{"type": "feature", "id": -1}]})),
("select.set", json!({"items": [{"type": "occurrence", "path": "1"}]})),
("select.set", json!({"items": [{"type": "face"}]})),
("select.set", json!({"items": [{"type": "face", "body": "Box1"}]})),
("select.set", json!({"items": [{"type": "face", "index": 0}]})),
("select.set", json!({"items": [{"type": "edge", "body": "Box1", "index": -1}]})),
("select.set", json!({"items": [{"type": "edge", "body": "Box1", "point": [1, 2]}]})),
("select.set", json!({"items": [{"type": "edge", "body": "Box1", "index": 0, "point": null}]})),
("select.set", json!({"items": [{"type": "face", "body": "Box1", "index": null, "point": [1, 2, 3]}]})),
("select.set", json!({"items": [{"type": "body", "name": "Missing"}]})),
("select.set", json!({"items": [{"type": "face", "body": "Missing", "point": [1, 2, 3]}]})),
]);
}
/// The item type reads each item as the handler does: face and edge items with one of `index`
/// and `point` are partial picks, everything else a `Sel`.
#[test]
fn the_item_type_reads_like_the_handler() {
let item = |v: Value| serde_json::from_value::<SelectItem>(v);
assert!(matches!(item(json!({"type": "body", "name": "A", "colour": 1})), Ok(SelectItem::Exact(_))));
assert!(matches!(item(json!({"type": "face", "body": "A", "index": 2})), Ok(SelectItem::Pick { face: true, .. })));
assert!(matches!(item(json!({"type": "edge", "body": "A", "point": [1, 2, 3], "extra": 1})), Ok(SelectItem::Pick { face: false, .. })));
assert!(matches!(item(json!({"type": "edge", "body": "A", "point": null})), Ok(SelectItem::Pick { .. })));
// Neither: the handler refuses it against the design, after it has looked up the body.
assert!(matches!(item(json!({"type": "face", "body": "A"})), Ok(SelectItem::Pick { .. })));
assert!(matches!(item(json!({"type": "face", "body": "A", "index": 2, "point": [1, 2, 3]})), Ok(SelectItem::Exact(_))));
assert!(matches!(item(json!({"type": "sketch_curve", "id": "c1"})), Ok(SelectItem::Exact(_))));
assert!(matches!(item(json!({"type": "profile", "sketch": 3, "index": 0})), Ok(SelectItem::Exact(_))));
// Both given: read whole, as a Sel, so a null refuses.
assert!(item(json!({"type": "face", "body": "A", "index": 2, "point": null})).is_err());
assert!(item(json!({"type": "face", "index": 2})).is_err());
assert!(item(json!({"type": "face", "body": "A", "index": "2"})).is_err());
assert!(item(json!({"type": "nothing"})).is_err() && item(json!(null)).is_err() && item(json!("body")).is_err());
// The messages are the handler's (what serde says), whole.
for bad in [json!({"type": "nothing"}), json!({"type": "face"}), json!({"type": "body"}), json!(3)] {
let handler = read_item(bad.clone()).err().unwrap_or_default();
let typed = item(bad.clone()).err().map(|e| e.to_string()).unwrap_or_default();
assert!(typed.ends_with(&handler), "{bad}: {typed} / {handler}");
}
}
/// The schema takes what the type takes, and the keys the params type reads.
#[test]
fn the_schemas_take_what_the_handler_reads() {
let check = |schema: schemars::Schema, cases: &[(Value, bool)]| {
let schema = schema.as_value().clone();
for (p, takes) in cases {
assert_eq!(conforms(&schema, &schema, p), *takes, "{p}");
}
};
let cases = [
(json!({}), true),
(json!({"items": []}), true),
(json!({"items": null, "add": null}), true),
(json!({"items": [{"type": "body", "name": "A"}], "add": true}), true),
(json!({"items": [{"type": "face", "body": "A", "index": 1}, {"type": "edge", "body": "A", "point": [1, 2, 3]}]}), true),
(json!({"items": [{"type": "face", "body": "A", "index": 1, "point": [1, 2, 3]}]}), true),
(json!({"items": [{"type": "plane", "name": "XY"}, {"type": "occurrence", "path": [1, 2]}, {"type": "feature", "id": 4}]}), true),
(json!({"items": [{"type": "sketch_curve", "id": "c1", "sketch": 2}, {"type": "profile", "sketch": 2, "index": 0}]}), true),
(json!({"items": [{"type": "face", "body": "A"}]}), false),
(json!({"items": [{"type": "bogus"}]}), false),
(json!({"items": [{"type": "body"}]}), false),
(json!({"items": "A"}), false),
(json!({"add": "yes"}), false),
(json!({"item": []}), false),
];
check(input_schema_for::<SelectSetParams>(), &cases);
check(input_schema_for::<SelectClearParams>(), &[(json!({}), true), (json!({"add": true}), false)]);
// The schema differs from the type in two cases: a face or edge with neither `index` nor
// `point`, which the type leaves to the handler (its message depends on the design), and an
// unknown key, which the key check reports, not the type.
let apart = [json!({"items": [{"type": "face", "body": "A"}]}), json!({"item": []})];
for (p, takes) in cases.iter().filter(|(p, _)| !apart.contains(p)) {
assert_eq!(serde_json::from_value::<SelectSetParams>(p.clone()).is_ok(), *takes, "{p}");
}
}
/// Real calls of both commands in Warn: no warnings, and the replies match the output schemas
/// and are what the handlers have always returned. A misspelt key gets its warning and the
/// reply still matches; strict refuses it.
#[test]
fn replies_match_the_output_schema() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box1"})).unwrap();
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0], "body_name": "Box2"})).unwrap();
let matches = |schema: schemars::Schema, r: &Value| {
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
};
let run = |s: &mut Session, id: &str, p: Value| {
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
r
};
let set = |s: &mut Session, p: Value| {
let r = run(s, "select.set", p);
matches(output_schema_for::<SelectSetResult>(), &r);
r
};
assert_eq!(set(&mut s, json!({})), json!({"selected": 0}));
assert_eq!(set(&mut s, json!({"items": [{"type": "body", "name": "Box1"}]})), json!({"selected": 1}));
assert_eq!(set(&mut s, json!({"items": [{"type": "body", "name": "Box2"}], "add": true})), json!({"selected": 2}));
assert_eq!(set(&mut s, json!({"items": [{"type": "body", "name": "Box2"}], "add": true})), json!({"selected": 2}));
assert_eq!(set(&mut s, json!({"items": null})), json!({"selected": 0}));
assert_eq!(set(&mut s, json!({"items": [{"type": "face", "body": "Box1", "index": 0}], "add": null})), json!({"selected": 1}));
assert_eq!(
set(&mut s, json!({"items": [{"type": "face", "body": "Box1", "point": [5, 5, 10]}, {"type": "edge", "body": "Box1", "index": 0}]})),
json!({"selected": 2})
);
let complete = s.selection[0].clone();
assert_eq!(set(&mut s, json!({"items": [complete, {"type": "plane", "name": "XY", "colour": "red"}], "add": true})), json!({"selected": 3}));
let r = run(&mut s, "select.clear", json!({}));
matches(output_schema_for::<SelectClearResult>(), &r);
assert_eq!(r, json!({"selected": 0}));
assert!(s.selection.is_empty());
// A misspelt key runs in warn and says so; the reply still matches.
let p = json!({"items": [{"type": "body", "name": "Box1"}], "ad": true});
let r = s.execute("select.set", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
assert_eq!(r["selected"], 1);
matches(output_schema_for::<SelectSetResult>(), &r);
let r = s.execute("select.clear", &json!({"all": true})).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
matches(output_schema_for::<SelectClearResult>(), &r);
s.param_check = ParamCheck::Strict;
let error = s.execute("select.set", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`ad`"), "{error}");
let error = s.execute("select.clear", &json!({"all": true})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`all`"), "{error}");
}
/// One item of every `Sel` variant, as `Sel` serializes it (and the forms it also reads): each is
/// a `Sel`, is read by the item type, matches the item schema, and is accepted in strict.
#[test]
fn every_selection_kind_is_taken() {
let items = [
json!({"type": "occurrence", "path": [1, 2]}),
json!({"type": "body", "name": "A"}),
json!({"type": "edge", "body": "A", "index": 1, "point": [1, 2, 3]}),
json!({"type": "face", "body": "A", "index": 1, "point": [1, 2, 3]}),
json!({"type": "feature", "id": 4}),
json!({"type": "sketch_curve", "id": "c1"}),
json!({"type": "sketch_curve", "id": "c1", "sketch": 2}),
json!({"type": "sketch_curve", "id": "c1", "sketch": null}),
json!({"type": "sketch_point", "id": "p1"}),
json!({"type": "sketch_midpoint", "id": "l1"}),
json!({"type": "external_sketch_curve", "sketch": 2, "id": "c1"}),
json!({"type": "external_sketch_point", "sketch": 2, "id": "p1"}),
json!({"type": "sketch_constraint", "id": "k1"}),
json!({"type": "profile", "sketch": 2, "index": 0}),
json!({"type": "plane", "name": "XY"}),
json!({"type": "construction_point", "name": "P"}),
json!({"type": "axis", "name": "X"}),
json!({"type": "vertex", "body": "A", "point": [0, 0, 0]}),
];
let schema = input_schema_for::<SelectSetParams>();
let schema = schema.as_value();
// `conforms` doesn't check `const`, so each item is also matched to the branch of its `type`.
let branches = schema["$defs"]["SelectItem"]["anyOf"].as_array().cloned().unwrap_or_default();
assert!(!branches.is_empty(), "no SelectItem branches in {schema}");
// Every branch is one of the kinds listed here.
for b in &branches {
assert!(items.iter().any(|i| i["type"] == b["properties"]["type"]["const"]), "{b} isn't tried");
}
for item in items {
assert!(serde_json::from_value::<Sel>(item.clone()).is_ok(), "{item} isn't a Sel");
assert!(serde_json::from_value::<SelectItem>(item.clone()).is_ok(), "{item}");
let kind = item["type"].as_str().unwrap_or_default();
let branch = branches.iter().find(|b| b["properties"]["type"]["const"] == kind);
assert!(branch.is_some_and(|b| conforms(schema, b, &item)), "no `{kind}` branch takes {item}");
let p = json!({"items": [item]});
assert!(conforms(schema, schema, &p), "{p} doesn't match {schema}");
}
}
/// Valid calls are not refused in Strict, and a wrong-typed `items` or `add`, which the handler
/// reads as if left out, is reported in Warn and refused in Strict.
#[test]
fn strict_takes_valid_calls_and_warn_reports_wrong_types() {
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "body_name": "Box1"})).unwrap();
for p in [
json!({}),
json!({"items": null, "add": null}),
json!({"items": [{"type": "body", "name": "Box1"}], "add": true}),
json!({"items": [{"type": "face", "body": "Box1", "index": 0}]}),
json!({"items": [{"type": "edge", "body": "Box1", "point": [0, 0, 0]}], "add": true}),
json!({"items": [{"type": "plane", "name": "XY", "colour": "red"}]}),
] {
let r = s.execute("select.set", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
}
assert_eq!(s.execute("select.clear", &json!({})).unwrap(), json!({"selected": 0}));
for p in [json!({"items": "Box1"}), json!({"add": "yes"})] {
s.param_check = ParamCheck::Warn;
let r = s.execute("select.set", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{p}: {r}");
s.param_check = ParamCheck::Strict;
assert!(s.execute("select.set", &p).is_err(), "{p}");
}
}
+298 -2
View File
@@ -1,5 +1,6 @@
//! Contracts for sheet metal feature and rule commands in `cmd/sheet.rs`. Feature commands
//! return [`FeatureResult`](super::features::FeatureResult); rule commands have their own replies.
//! Contracts for sheet metal feature, rule, flat pattern and bend table commands in
//! `cmd/sheet.rs`. Feature commands return [`FeatureResult`](super::features::FeatureResult); the
//! others have their own replies.
//!
//! A sheet metal rule sets the sheet's thickness, K-factor, bend radius and reliefs: the
//! commands refuse those keys with a pointer to `sheet.manage_rules`, so they are no keys of
@@ -752,6 +753,301 @@ pub enum ManageRulesResult {
},
}
/// A point in mm on a sheet edge, `[x, y, z]` or `{x, y, z}`, with a path flange's message for
/// `edge`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SheetEdgePoint(pub Vec3);
impl<'de> Deserialize<'de> for SheetEdgePoint {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
crate::params::vec3(&Value::deserialize(d)?).map(SheetEdgePoint).ok_or_else(|| D::Error::custom("must be a point [x, y, z] on a sheet edge"))
}
}
impl JsonSchema for SheetEdgePoint {
fn schema_name() -> Cow<'static, str> {
"SheetEdgePoint".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
Point3::json_schema(generator)
}
}
/// Add a path flange: flanges along an open chain of sketch lines drawn on a plane across a
/// sheet edge, from the edge out. Each line is one flange as long as the line (to the outer face
/// from the virtual sharp) and each corner one bend, turning the way the lines turn. One
/// feature, one body. `participants` changes nothing.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PathFlangeParams {
/// A point on a top or bottom edge of the sheet, where the path starts.
pub edge: SheetEdgePoint,
/// The sketch holding the path, by id or name.
pub sketch: SketchRef,
/// The path's lines: 1 to 64 lines of `sketch`, by id, in one open chain, in any order. Arcs
/// are refused.
pub curves: StringList,
/// The inner bend radius. Default: the sheet's rule's.
#[serde(default)]
pub radius: Option<LengthExpr>,
/// The sheet metal body. Default: the sheet with an edge nearest `edge`.
#[serde(default)]
pub body: Option<String>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Read a sheet's flat pattern: its size, outline, cut-outs, pockets, bends and perforations, in
/// flat coordinates (mm, on the base panel's bottom face). Refused while a feature that is not
/// sheet metal (a fillet, a join) has changed the body.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct FlatPatternParams {
/// The sheet metal body. Default: the last sheet.
#[serde(default)]
pub body: Option<String>,
}
/// Export a sheet's bend table: a row per bend, in mm and degrees, with the rule and the bend's
/// centre line in flat coordinates. A bend that cut-outs split has a row for each piece.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct BendTableParams {
/// The sheet metal body. Default: the last sheet.
#[serde(default)]
pub body: Option<String>,
/// csv (a header and a row per bend, quoted body and rule names, CRLF line ends) or json (the
/// flat pattern that `sheet.flat_pattern` returns, as text). Default: csv.
#[serde(default)]
pub format: Option<BendTableFormatParam>,
/// The file to write. Default (or blank): no file; the reply holds the table's text.
#[serde(default)]
pub path: Option<String>,
}
/// The form of a bend table.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum BendTableFormat {
Csv,
Json,
}
/// A bend table's `format`, in any case (read by `sheet::bend_table_format`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BendTableFormatParam(pub BendTableFormat);
impl<'de> Deserialize<'de> for BendTableFormatParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
v.as_str().and_then(crate::cmd::bend_table_format).map(BendTableFormatParam).ok_or_else(|| D::Error::custom("must be csv or json"))
}
}
impl JsonSchema for BendTableFormatParam {
fn schema_name() -> Cow<'static, str> {
"BendTableFormat".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "csv or json, in any case.", "type": "string", "pattern": "^([Cc][Ss][Vv]|[Jj][Ss][Oo][Nn])$"})
}
}
/// A point `[x, y]` in flat coordinates, in mm.
pub type FlatPoint = [f64; 2];
/// One curve of a cut-out's exact shape.
#[derive(Debug, serde::Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum FlatSegment {
/// A straight line from its first point to its second.
Line([FlatPoint; 2]),
/// A circular arc.
Arc {
center: FlatPoint,
radius: f64,
/// Where the arc starts, in degrees counterclockwise from +X.
start_deg: f64,
/// How far it turns, in degrees: positive counterclockwise.
sweep_deg: f64,
},
/// Any other curve (an ellipse, a spline), as points within 0.001 mm of it.
Polyline(Vec<FlatPoint>),
}
/// A circle's centre and radius, in mm.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub struct FlatCircle {
pub center: FlatPoint,
pub radius: f64,
}
/// A cut-out's exact shape: a whole circle, or its curves in order.
#[derive(Debug, serde::Serialize, JsonSchema)]
#[serde(untagged)]
pub enum FlatShape {
Circle { circle: FlatCircle },
Segments { segments: Vec<FlatSegment> },
}
/// Which side of the sheet a pocket is machined from.
#[derive(Debug, serde::Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum PocketSide {
/// From the flat's bottom face.
Bottom,
/// From its top face.
Top,
/// Inside the sheet, open to neither face.
Internal,
}
/// A pocket: material machined to a depth, not through the sheet.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub struct FlatPocket {
/// Its outer contour, as points within 0.001 mm.
pub outline: Vec<FlatPoint>,
/// Islands inside it that keep their material, as points within 0.001 mm.
pub cutouts: Vec<Vec<FlatPoint>>,
/// The same islands exactly, in the same order.
pub cutout_shapes: Vec<FlatShape>,
pub side: PocketSide,
/// `z1_mm` less `z0_mm`.
pub depth_mm: f64,
/// The pocket's bottom and top, measured from the flat's bottom face, in mm.
pub z0_mm: f64,
pub z1_mm: f64,
}
/// Which way a bend folds: up toward the flat's top face (+z), down toward its bottom.
#[derive(Debug, serde::Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum BendDirection {
Up,
Down,
}
/// A bend of the flat pattern.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub struct FlatBend {
/// The bend's number, from 1. The stretches that cut-outs leave of one bend each have a row,
/// with the same number.
pub bend: usize,
/// The bend angle, in degrees.
pub angle_deg: f64,
/// The angle left between the bend's sides, in degrees: 180 less `angle_deg`.
pub included_angle_deg: f64,
/// The inner bend radius, in mm.
pub radius: f64,
pub direction: BendDirection,
/// The bend allowance (the bend zone's width in the flat), in mm.
pub allowance: f64,
/// The length of this stretch of the bend's centre line, in mm.
pub length: f64,
/// The bend's centre line, from `start` to `end`.
pub start: FlatPoint,
pub end: FlatPoint,
}
/// How a perforation field lays out its holes.
#[derive(Debug, serde::Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum FlatPerforationPattern {
/// Rows offset for a 60° pattern.
Staggered,
Square,
}
/// A perforation field: round holes in the flat pattern, not cut into the solid.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub struct FlatPerforation {
/// The `sheet.perforate` feature's id.
pub feature: u64,
/// The holes' diameter, the distance between their centres and how far they stay clear of
/// edges, cut-outs and bends, in mm.
pub diameter: f64,
pub pitch: f64,
pub margin: f64,
pub pattern: FlatPerforationPattern,
/// How many holes the field has.
pub count: usize,
/// The holes' area together, in mm².
pub open_area_mm2: f64,
}
/// A hole drilled along a panel into its rim, drilled after cutting and not in the outline.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub struct FlatBore {
/// Where it starts, in flat coordinates with z up from the bottom face, in mm.
pub start: [f64; 3],
/// Its direction, a unit vector.
pub axis: [f64; 3],
/// Its diameter and length, in mm.
pub diameter: f64,
pub length: f64,
}
/// What `sheet.flat_pattern` returns: the sheet's flat pattern in flat coordinates (mm, on the
/// base panel's bottom face). The `json` form of `sheet.export_bend_table` is this as text.
#[derive(Debug, serde::Serialize, JsonSchema)]
pub struct FlatPatternResult {
/// The sheet metal body.
pub body: String,
/// Its sheet metal rule, by name.
pub rule: String,
/// The sheet's thickness, in mm.
pub thickness: f64,
/// The K-factor its bends unfold with.
pub k_factor: f64,
/// The flat pattern's width and height (its bounding box) and its thickness, in mm.
pub flat_size_mm: [f64; 3],
/// The outer contours, one per piece, as points within 0.001 mm.
pub outline: Vec<Vec<FlatPoint>>,
/// The cut-outs through the sheet, as points within 0.001 mm.
pub cutouts: Vec<Vec<FlatPoint>>,
/// The same cut-outs exactly, in the same order.
pub cutout_shapes: Vec<FlatShape>,
pub pockets: Vec<FlatPocket>,
pub bends: Vec<FlatBend>,
/// Whether the sheet is unfolded now.
pub unfolded: bool,
pub perforations: Vec<FlatPerforation>,
/// Each bend zone's bounding box, `[min x, max x, min y, max y]` in mm.
pub bend_zones: Vec<[f64; 4]>,
/// Left out when there are none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bores: Vec<FlatBore>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What `sheet.export_bend_table` returns: the file it wrote, or the table's text.
#[derive(Debug, serde::Serialize, JsonSchema)]
#[serde(untagged)]
pub enum BendTableResult {
/// The table went to `path`.
Written {
path: String,
format: BendTableFormat,
/// How many rows the table has: one per bend, or per stretch of a bend that cut-outs split.
bends: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
param_warnings: Vec<String>,
},
/// No `path`: the table's text.
Text {
format: BendTableFormat,
text: String,
/// How many rows the table has: one per bend, or per stretch of a bend that cut-outs split.
bends: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
param_warnings: Vec<String>,
},
}
#[cfg(test)]
#[path = "sheet_tests.rs"]
mod tests;
+198 -2
View File
@@ -2,8 +2,9 @@ use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use super::{
ConvertParams, FixedPoint, FlangeEdges, FlangeParams, FlangePosition, FlangeTypeParam, FoldParams, FoldPoints, FoldPosition, HeightFromParam,
HemParams, PerforateParams, PerforationPattern, ReliefShapeParam, SheetPoints, ThenSteps, UnfoldParams,
BendTableFormatParam, BendTableParams, BendTableResult, ConvertParams, FixedPoint, FlangeEdges, FlangeParams, FlangePosition, FlangeTypeParam,
FlatPatternParams, FlatPatternResult, FoldParams, FoldPoints, FoldPosition, HeightFromParam, HemParams, PathFlangeParams, PerforateParams,
PerforationPattern, ReliefShapeParam, SheetEdgePoint, SheetPoints, ThenSteps, UnfoldParams,
};
use crate::Session;
use crate::schema::features::FeatureResult;
@@ -69,6 +70,9 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<FoldParams>(), &keys(&["angle", "body", "curve", "fixed", "flip", "points", "position", "radius", "sketch"]));
assert_keys(input_schema_for::<UnfoldParams>(), &keys(&["body"]));
assert_keys(input_schema_for::<ConvertParams>(), &keys(&["bends", "body", "face", "rule"]));
assert_keys(input_schema_for::<PathFlangeParams>(), &keys(&["body", "curves", "edge", "radius", "sketch"]));
assert_keys(input_schema_for::<FlatPatternParams>(), &["body"]);
assert_keys(input_schema_for::<BendTableParams>(), &["body", "format", "path"]);
}
fn parse<T: DeserializeOwned>(v: Value) -> Option<T> {
@@ -157,6 +161,7 @@ fn the_value_types_match_the_readers() {
}
for (v, ok) in [(json!([1, 2, 3]), true), (json!({"x": 1, "y": 2, "z": 3}), true), (json!([1, 2]), false), (json!(null), false)] {
assert_eq!(takes::<FixedPoint>(&v), ok, "{v}");
assert_eq!(takes::<SheetEdgePoint>(&v), ok, "{v}");
}
for (v, ok) in [
(json!("staggered"), true),
@@ -185,6 +190,17 @@ fn the_value_types_match_the_readers() {
words(&["outer", " Inner ", "TANGENT"], &[json!("sharp"), json!(""), json!(5)], &|v| parse::<HeightFromParam>(v).is_some());
words(&["centerline", "Start", "END", "mould", "Mold"], &[json!("center"), json!("side"), json!(null)], &|v| parse::<FoldPosition>(v).is_some());
words(&["notch", " Round ", "rectangular", "TEAR"], &[json!("oval"), json!(""), json!(5)], &|v| parse::<ReliefShapeParam>(v).is_some());
// A bend table's format: what the handler reads, in any case but without blanks.
let formats = ["csv", "CSV", "Json", "jSoN"];
words(&formats, &[json!(" csv"), json!("xml"), json!(""), json!(5), json!(null)], &|v| parse::<BendTableFormatParam>(v).is_some());
for f in formats {
assert!(crate::cmd::bend_table_format(f).is_some(), "{f}");
}
for f in [" csv", "csv ", "xml", "", "csv"] {
assert!(crate::cmd::bend_table_format(f).is_none(), "{f}");
}
let format = input_schema_for::<BendTableFormatParam>();
assert_eq!(format.as_value()["pattern"], "^([Cc][Ss][Vv]|[Jj][Ss][Oo][Nn])$");
}
#[test]
@@ -218,6 +234,14 @@ fn strict_keeps_todays_errors() {
("sheet.fold", json!({"points": [[0, 0, 0], [10, 0, 0]], "fixed": null})),
("sheet.fold", json!({"sketch": true, "curve": "l1"})),
("sheet.convert", json!({"body": "Nothing", "face": [0, 0, 0]})),
("sheet.path_flange", json!({"edge": [1, 2], "sketch": "Path", "curves": ["p1"]})),
("sheet.path_flange", json!({"edge": null, "sketch": "Path", "curves": ["p1"]})),
("sheet.path_flange", json!({"edge": [1, 0, 20], "sketch": true, "curves": ["p1"]})),
("sheet.flat_pattern", json!({})),
("sheet.flat_pattern", json!({"body": "Nothing"})),
("sheet.export_bend_table", json!({"format": "xml"})),
("sheet.export_bend_table", json!({"format": " csv", "body": "Nothing"})),
("sheet.export_bend_table", json!({"format": "JSON", "body": "Nothing"})),
]);
// Calls that get past the handler's first checks only on a sheet.
for (id, p) in [
@@ -227,6 +251,10 @@ fn strict_keeps_todays_errors() {
("sheet.convert", json!({"body": "Plate", "face": [50, 130, 2.5], "bends": [[1, 2]]})),
("sheet.convert", json!({"body": "Plate", "face": ""})),
("sheet.convert", json!({"body": "Plate", "face": true})),
("sheet.path_flange", json!({"edge": [50, 0, 2.5], "sketch": "Base", "curves": []})),
("sheet.path_flange", json!({"edge": [50, 0, 2.5], "sketch": "Base", "curves": vec!["l1"; 65]})),
("sheet.path_flange", json!({"edge": [50, 0, 2.5], "sketch": "Base", "curves": ["l1"], "radius": "2 deg"})),
("sheet.export_bend_table", json!({"format": "yaml"})),
] {
let error = |level: ParamCheck| {
let mut s = sheet();
@@ -491,3 +519,171 @@ fn rule_warnings_match_the_output_schema_and_strict_does_not_mutate() {
assert!(conforms(schema.as_value(), schema.as_value(), &reply));
}
}
/// Runs `id` in `s` and returns its reply, which carries no warnings.
fn quiet(s: &mut Session, id: &str, p: Value) -> Value {
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert_eq!(r.get("param_warnings"), None, "{id} {p}: {r}");
r
}
/// The wall bracket of the path flange tests in `cmd/sheet_tests.rs`, in Warn: a 150 × 100 base
/// of a 2 mm rule with a 40 mm wall up its edge at x = 0, and the lines `p1` and `p2` of sketch
/// `Path` drawn across the wall's end edge (y = 0) at half its height.
fn path_bracket() -> Session {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
for (id, p) in [
(
"sheet.manage_rules",
json!({"name": "Demo", "thickness": "2 mm", "bend_radius": "2 mm", "gap": "0.5 mm", "k_factor": "0.4", "active": true}),
),
("sketch.create", json!({"plane": "XY", "name": "Base"})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [150, 100]})),
("sketch.finish", json!({})),
("sheet.flange", json!({"sketch": "Base"})),
("sheet.flange", json!({"edges": [[0, 50, 2]], "height": 40})),
("sketch.create", json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "name": "Path"})),
("sketch.line", json!({"points": [[0, 0], [20, -20], [100, -20]], "ids": ["p1", "p2"]})),
("sketch.finish", json!({})),
] {
quiet(&mut s, id, p);
}
s
}
#[test]
fn path_flange_replies_match_the_output_schema() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
// Each form, in Warn, without warnings. The path's two lines make two bends besides the
// wall's; one line makes one.
for (p, bends) in [
(json!({"edge": [1, 0, 20], "sketch": "Path", "curves": ["p1", "p2"]}), 3),
(json!({"edge": {"x": 1, "y": 0, "z": 20}, "sketch": "Path", "curves": "p1", "radius": "1 mm", "body": "?", "name": "Flap"}), 2),
(json!({"edge": [1, 0, 20], "sketch": "Path", "curves": ["p2", "p1"], "radius": null, "body": null}), 3),
] {
let mut s = path_bracket();
let body = s.model.state().sheets.first().map(|sh| sh.body.clone()).unwrap_or_default();
let mut p = p;
if p["body"] == "?" {
p["body"] = json!(body);
}
let r = quiet(&mut s, "sheet.path_flange", p.clone());
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
let flat = quiet(&mut s, "sheet.flat_pattern", json!({}));
assert_eq!(flat["bends"].as_array().map(Vec::len), Some(bends), "{p}: {flat}");
}
// A misspelt key: the call runs, and the reply carrying its warning still matches.
let mut s = path_bracket();
let r = s
.execute("sheet.path_flange", &json!({"edge": [1, 0, 20], "sketch": "Path", "curves": ["p1"], "radiuss": 1}))
.unwrap_or_else(|e| panic!("{e}"));
assert!(r.get("param_warnings").is_some(), "{r}");
assert!(conforms(schema, schema, &r), "{r}");
}
/// The 100 × 60 sheet with a flange up its edge at y = 0, a 6 mm hole at (30, 30), a 1 mm deep
/// pocket from below, a perforation field and a 1.5 mm bore into its rim at x = 100; in Warn.
fn worked_sheet() -> Session {
let mut s = sheet();
for (id, p) in [
("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})),
("solid.hole", json!({"position": [30, 30, 2.5], "direction": [0, 0, -1], "diameter": 6})),
("sketch.create", json!({"plane": "XY", "name": "Pocket"})),
("sketch.rectangle.two_point", json!({"p0": [60, 20], "p1": [70, 30]})),
("sketch.finish", json!({})),
("solid.extrude", json!({"sketch": "Pocket", "distance": 1, "operation": "cut"})),
("sheet.perforate", json!({"diameter": 2, "pitch": 6})),
("solid.hole", json!({"position": [100, 45, 1.25], "direction": [-1, 0, 0], "diameter": 1.5})),
] {
if let Err(e) = s.execute(id, &p) {
panic!("{id}: {e}");
}
}
s.param_check = ParamCheck::Warn;
s
}
#[test]
fn flat_pattern_replies_match_the_output_schema() {
let schema = output_schema_for::<FlatPatternResult>();
let schema = schema.as_value();
let mut s = worked_sheet();
let body = s.model.state().sheets.first().map(|sh| sh.body.clone()).unwrap_or_default();
let f = quiet(&mut s, "sheet.flat_pattern", json!({}));
assert!(conforms(schema, schema, &f), "{f} doesn't match {schema}");
// Every part of the reply is there, from the features above.
assert_eq!(f["body"], json!(body));
assert_eq!(f["rule"], STEEL);
assert_eq!(f["unfolded"], false);
assert_eq!(f["bends"].as_array().map(Vec::len), Some(1), "{f}");
assert_eq!((&f["bends"][0]["angle_deg"], &f["bends"][0]["included_angle_deg"]), (&json!(90.0), &json!(90.0)), "{f}");
let circles: Vec<&Value> = f["cutout_shapes"].as_array().into_iter().flatten().filter_map(|c| c.get("circle")).collect();
assert_eq!(circles.len(), 1, "{f}");
assert!((circles[0]["radius"].as_f64().unwrap_or_default() - 3.0).abs() < 1e-9, "{f}");
assert_eq!(f["pockets"].as_array().map(Vec::len), Some(1), "{f}");
assert_eq!((&f["pockets"][0]["side"], &f["pockets"][0]["depth_mm"]), (&json!("bottom"), &json!(1.0)), "{f}");
assert!(f["pockets"][0]["cutout_shapes"].is_array(), "{f}");
let perforation = &f["perforations"][0];
let count = perforation["count"].as_f64().unwrap_or_default();
assert!(count > 0.0, "{f}");
assert_eq!(perforation["pattern"], "staggered");
assert!((perforation["open_area_mm2"].as_f64().unwrap_or_default() - count * std::f64::consts::PI).abs() < 1e-6, "{f}");
assert_eq!(f["bores"].as_array().map(Vec::len), Some(1), "{f}");
assert!((f["bores"][0]["diameter"].as_f64().unwrap_or_default() - 1.5).abs() < 1e-9, "{f}");
assert_eq!(f["bend_zones"].as_array().map(Vec::len), Some(1), "{f}");
// The same sheet by name, and with a null body.
assert_eq!(quiet(&mut s, "sheet.flat_pattern", json!({"body": body})), f);
assert_eq!(quiet(&mut s, "sheet.flat_pattern", json!({"body": null})), f);
// Unfolded, and a plain sheet without bores, which the reply leaves out.
quiet(&mut s, "sheet.unfold", json!({}));
let unfolded = quiet(&mut s, "sheet.flat_pattern", json!({}));
assert_eq!(unfolded["unfolded"], true);
assert!(conforms(schema, schema, &unfolded), "{unfolded}");
let mut plain = sheet();
plain.param_check = ParamCheck::Warn;
let r = quiet(&mut plain, "sheet.flat_pattern", json!({}));
assert!(r.get("bores").is_none(), "{r}");
assert!(conforms(schema, schema, &r), "{r}");
// A misspelt key: the call runs, and the reply carrying its warning still matches.
let r = plain.execute("sheet.flat_pattern", &json!({"bodyy": body})).unwrap_or_else(|e| panic!("{e}"));
assert!(r.get("param_warnings").is_some(), "{r}");
assert!(conforms(schema, schema, &r), "{r}");
}
#[test]
fn bend_table_replies_match_the_output_schema() {
let schema = output_schema_for::<BendTableResult>();
let schema = schema.as_value();
let mut s = worked_sheet();
let body = s.model.state().sheets.first().map(|sh| sh.body.clone()).unwrap_or_default();
let flat = quiet(&mut s, "sheet.flat_pattern", json!({}));
let rows = flat["bends"].as_array().map_or(0, Vec::len);
let table = |s: &mut Session, p: Value| {
let r = quiet(s, "sheet.export_bend_table", p);
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert_eq!(r["bends"], json!(rows), "{r}");
r
};
// csv: a header and a row per bend.
let csv = table(&mut s, json!({}));
assert_eq!(csv["format"], "csv");
let text = csv["text"].as_str().unwrap_or_default().to_string();
assert_eq!(text.matches("\r\n").count(), rows + 1, "{text}");
assert_eq!(table(&mut s, json!({"format": "CSV", "path": " ", "body": null})), csv);
// json: the flat pattern as text.
let json = table(&mut s, json!({"format": "Json", "body": body}));
assert_eq!(json["format"], "json");
assert_eq!(serde_json::from_str::<Value>(json["text"].as_str().unwrap_or_default()).ok(), Some(flat));
// To a file: the reply names it, and the file holds the text.
let path = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-bend-table-{}.csv", std::process::id()));
let path = path.to_string_lossy().to_string();
let written = table(&mut s, json!({"format": null, "path": path}));
assert_eq!((written.get("text"), &written["path"], &written["format"]), (None, &json!(path), &json!("csv")), "{written}");
assert_eq!(std::fs::read_to_string(&path).ok(), Some(text));
let _ = std::fs::remove_file(&path);
// A misspelt key: the call runs, and the reply carrying its warning still matches.
let r = s.execute("sheet.export_bend_table", &json!({"formatt": "json"})).unwrap_or_else(|e| panic!("{e}"));
assert!(r.get("param_warnings").is_some(), "{r}");
assert!(conforms(schema, schema, &r), "{r}");
}
+448
View File
@@ -0,0 +1,448 @@
//! Contracts and result types for the commands in `cmd/sketch_import.rs`: `sketch.insert_dxf`,
//! `sketch.insert_svg` and `sketch.export_dxf`.
//!
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
//! reads it and fails with the handler's message. Whether a file can be read or written, a sketch
//! exists or a drawing has anything to import stays the handler's to check. A call with several
//! problems may get a different one first in strict than with the check off (the check goes key
//! by key, the handlers in their own order).
//!
//! Two insert keys are read only in some cases, and the contract reads them in the same cases:
//! `path` only when `text` is not given, and of `width`, `height` and `scale` only the first one
//! given, in that order (a `height` next to a `width`, or a `scale` next to either, is never read).
//! A value of the wrong type that the handler reads as if the key were left out (`text: 5` next
//! to a good `path`, a text for `scale`) is refused by the contract, as schemas.md has it: that
//! difference is what warn and strict report.
//! `plane` and `name` are read only when no sketch is being edited, which the check can't see
//! (it has no session), so they take any value.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use solidcraft_io::MAX_DRAWING_BYTES;
use super::check::{located, whole};
use super::sketch_ends::SketchTarget;
use super::types::{Point2, not_blank_pattern};
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
T::deserialize(d).map(Some)
}
/// The keys the two insert commands read. See [`InsertDxfParams`] and [`InsertSvgParams`], which
/// leave `path`, `height` and `scale` unread in the cases the handler does.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = source_required)]
pub struct InsertDrawingKeys {
/// The drawing itself, as text. Wins over `path`. At most 16 MiB (`MAX_DRAWING_BYTES`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<DrawingText>,
/// The drawing file to read (not blank, fewer than 4096 UTF-8 bytes). Used when `text` is
/// not given; ignored when it is.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "path_schema")]
pub path: Option<DrawingPath>,
/// An offset, in mm, [x, y]. Default: [0, 0]. With `align` "origin" it is where the file's
/// origin goes; with "center", where the drawing's middle goes.
#[serde(default)]
pub at: Option<Point2>,
/// A size factor, 1e-9 to 1e6 (a value outside is read as 1). Default: 1. Used only when
/// neither `width` nor `height` is given.
#[serde(default)]
pub scale: Option<f64>,
/// The drawing's width in mm after sizing, before rotation; the aspect ratio is kept. Wins
/// over `height` and `scale`. The drawing must have a width.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PositiveMm")]
pub width: Option<PositiveMm>,
/// The drawing's height in mm after sizing, before rotation; the aspect ratio is kept. Wins
/// over `scale`; ignored when `width` is given. The drawing must have a height.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "PositiveMm")]
pub height: Option<PositiveMm>,
/// A turn in degrees, about the drawing's origin (with `align` "center": about its middle).
/// Default: 0.
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
#[schemars(with = "AngleDegrees")]
pub angle: Option<AngleDegrees>,
/// What `at` places: the file's origin ("origin") or the drawing's middle ("center", which it
/// then also turns and scales about). Default: origin.
#[serde(default)]
pub align: Option<DrawingAlign>,
/// The distance in mm within which ends join into one point, 1e-9 to 10 (a value outside is
/// brought inside; one that is not above 0 reads as the default). Default: 0.0001.
#[serde(default)]
pub tolerance: Option<f64>,
/// Read only when no sketch is being edited: the plane of the new sketch, as `sketch.create`
/// takes it. Default: XY. Ignored (and not checked) while a sketch is being edited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plane: Option<Value>,
/// Read only when no sketch is being edited: the new sketch's name. Ignored (and not checked)
/// while a sketch is being edited.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<Value>,
/// The sketch to insert into: its feature id or its name. Default: the one being edited (a
/// new one when none is).
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// Leaves out the keys the handler never reads for this call: `path` next to a `text` that is
/// text, `height` next to a `width`, and `scale` next to either.
fn leave_unread(value: &mut Value) {
let Some(keys) = value.as_object_mut() else { return };
if keys.get("text").is_some_and(Value::is_string) {
keys.remove("path");
}
if keys.contains_key("width") {
keys.remove("height");
}
if keys.contains_key("width") || keys.contains_key("height") {
keys.remove("scale");
}
}
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<InsertDrawingKeys, D::Error> {
let mut value = Value::deserialize(d)?;
leave_unread(&mut value);
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
}
/// Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none
/// is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be
/// sized, turned and placed. Text in the drawing becomes sketch text. One undo step.
#[derive(Debug)]
pub struct InsertDxfParams(pub InsertDrawingKeys);
impl<'de> Deserialize<'de> for InsertDxfParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(InsertDxfParams)
}
}
impl JsonSchema for InsertDxfParams {
fn schema_name() -> Cow<'static, str> {
"InsertDxfParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertDrawingKeys::json_schema(generator);
schema.insert("description".into(), INSERT_DXF.into());
schema
}
}
const INSERT_DXF: &str = "Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
/// Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is
/// being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed.
/// Text in the drawing becomes sketch text. One undo step.
#[derive(Debug)]
pub struct InsertSvgParams(pub InsertDrawingKeys);
impl<'de> Deserialize<'de> for InsertSvgParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
read_keys(d).map(InsertSvgParams)
}
}
impl JsonSchema for InsertSvgParams {
fn schema_name() -> Cow<'static, str> {
"InsertSvgParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = InsertDrawingKeys::json_schema(generator);
schema.insert("description".into(), INSERT_SVG.into());
schema
}
}
const INSERT_SVG: &str = "Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
/// The insert commands need `path` or `text`; the handler says so when both are left out. A
/// `path` it reads (no `text`) is not blank and under 4096 bytes; `maxLength` counts
/// characters, so 4095 is as close as the schema gets. `height` is read only without a `width`
/// and `scale` only without either, so their shapes apply only then, as [`leave_unread`] has it.
fn source_required(schema: &mut Schema) {
let read_path = serde_json::json!({"type": "string", "maxLength": 4095, "pattern": not_blank_pattern()});
schema.insert("anyOf".into(), serde_json::json!([{"required": ["text"]}, {"required": ["path"], "properties": {"path": read_path}}]));
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
let mut read_only_without = |key: &str| {
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
shape
};
let height = read_only_without("height");
let scale = read_only_without("scale");
schema.insert(
"allOf".into(),
serde_json::json!([
{"anyOf": [{"required": ["width"]}, {"properties": {"height": height}}]},
{"anyOf": [{"required": ["width"]}, {"required": ["height"]}, {"properties": {"scale": scale}}]},
]),
);
}
/// `path` is checked by [`source_required`] only when it is read (no `text`).
fn path_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({})
}
/// A drawing's text, as `source_text` reads it: at most 16 MiB, or the handler's refusal.
#[derive(Clone, Debug, PartialEq)]
pub struct DrawingText(pub String);
impl<'de> Deserialize<'de> for DrawingText {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let text = String::deserialize(d)?;
if text.len() > MAX_DRAWING_BYTES {
return Err(D::Error::custom(whole("the drawing is too large")));
}
Ok(DrawingText(text))
}
}
impl JsonSchema for DrawingText {
fn schema_name() -> Cow<'static, str> {
"DrawingText".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "string"})
}
}
/// A drawing file name, as `source_text` reads `path`: not blank and under 4096 bytes, or the
/// handler's "give `path` or `text`" (anything but text reads as no `path`).
#[derive(Clone, Debug, PartialEq)]
pub struct DrawingPath(pub String);
impl<'de> Deserialize<'de> for DrawingPath {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(DrawingPath(s)),
_ => Err(D::Error::custom(whole("give `path` or `text`"))),
}
}
}
/// A size in mm above 0, as the insert commands' `width` and `height` are read.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PositiveMm(pub f64);
/// The message the insert commands give for `width` or `height`: the check puts the key first.
const POSITIVE_MM: &str = "must be a positive number (mm)";
impl<'de> Deserialize<'de> for PositiveMm {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_f64().filter(|w| w.is_finite() && *w > 0.0).map(PositiveMm).ok_or_else(|| D::Error::custom(POSITIVE_MM))
}
}
impl JsonSchema for PositiveMm {
fn schema_name() -> Cow<'static, str> {
"PositiveMm".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A size in mm, above 0.", "type": "number", "exclusiveMinimum": 0})
}
}
/// An angle in degrees as `angle` is read: a number, whatever its size.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AngleDegrees(pub f64);
impl<'de> Deserialize<'de> for AngleDegrees {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_f64().filter(|a| a.is_finite()).map(AngleDegrees).ok_or_else(|| D::Error::custom("must be a number (degrees)"))
}
}
impl JsonSchema for AngleDegrees {
fn schema_name() -> Cow<'static, str> {
"AngleDegrees".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "An angle in degrees.", "type": "number"})
}
}
/// What an insert's `at` places, as `align` is read (the words are case-sensitive).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DrawingAlign {
/// `at` is where the file's origin goes.
Origin,
/// `at` is where the drawing's middle goes, and it turns and scales about it.
Center,
}
impl JsonSchema for DrawingAlign {
fn schema_name() -> Cow<'static, str> {
"DrawingAlign".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "What `at` places: \"origin\" (the file's origin goes there) or \"center\" (the drawing's middle goes there, and it turns and scales about it). Lower case only.",
"type": "string",
"enum": ["origin", "center"]
})
}
}
impl<'de> Deserialize<'de> for DrawingAlign {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)?.as_str() {
Some("origin") => Ok(DrawingAlign::Origin),
Some("center") => Ok(DrawingAlign::Center),
_ => Err(D::Error::custom("must be \"origin\" or \"center\"")),
}
}
}
/// What `sketch.insert_dxf` and `sketch.insert_svg` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DrawingInserted {
/// The sketch the drawing went into, by feature id.
pub sketch: u64,
/// The new sketch's id when no sketch was being edited and one was made; else null.
pub created_sketch: Option<u64>,
/// How many curves were added.
pub curves: usize,
/// How many sketch texts were added.
pub texts: usize,
/// The sketch's solve afterwards; null when it was not solved.
pub info: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// A sketch's solve, as the sketch commands report it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SketchSolve {
/// Whether the solve kept the sketch's orientation.
pub solved: bool,
/// The degrees of freedom left (0 = fully constrained).
pub dof: usize,
}
/// Save a sketch's curves as an ASCII DXF: to the file `path`, or, without it, as text in the
/// reply. Construction curves are left out; splines and other curves are written as line
/// segments.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ExportDxfParams {
/// The sketch: its feature id or its name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// The file to write. Default (also when blank): none, and the DXF text is returned.
#[serde(default)]
pub path: Option<String>,
/// The drawing's units (any case): `design` (the design's length unit), `mm`, `in` or
/// `inch`, `cm`, `m` or `ft`. Default: mm.
#[serde(default, deserialize_with = "dxf_units")]
#[schemars(schema_with = "dxf_units_schema")]
pub units: Option<String>,
/// The layer of the profiles (blank: the default). Default: `0`.
#[serde(default, deserialize_with = "dxf_layer")]
#[schemars(schema_with = "dxf_layer_schema")]
pub layer: Option<String>,
/// `single` (any case, the default): everything on `layer`; `roles`: outer loops on
/// `OUTLINE` and holes on `CUT`.
#[serde(default, deserialize_with = "dxf_layers")]
#[schemars(schema_with = "dxf_layers_schema")]
pub layers: Option<String>,
/// `true` writes closed loops as LWPOLYLINE with arc bulges. Default: false (LINE and ARC).
#[serde(default, deserialize_with = "dxf_join")]
pub join: Option<bool>,
}
/// The units `sketch.export_dxf` takes, in lower case (the handler reads them in any case).
const DXF_UNITS: [&str; 7] = ["design", "mm", "in", "inch", "cm", "m", "ft"];
fn dxf_units<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(units)) => {
let lower = units.to_ascii_lowercase();
if DXF_UNITS.contains(&lower.as_str()) {
Ok(Some(lower))
} else {
Err(D::Error::custom(whole(format!("unsupported unit `{lower}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")"))))
}
}
Some(_) => Err(D::Error::custom(whole("`units` must be a string"))),
}
}
fn dxf_units_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "design, mm, in, inch, cm, m or ft, in any case. Default: mm."})
}
fn dxf_layer<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(layer)) => Ok(Some(layer)),
Some(_) => Err(D::Error::custom(whole("`layer` must be a string"))),
}
}
fn dxf_layer_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "The profiles' layer (blank: the default). Default: 0."})
}
fn dxf_layers<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::String(layers)) if ["single", "roles"].contains(&layers.to_ascii_lowercase().as_str()) => Ok(Some(layers.to_ascii_lowercase())),
Some(_) => Err(D::Error::custom(whole("`layers` must be \"single\" or \"roles\""))),
}
}
fn dxf_layers_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": ["string", "null"], "description": "single or roles, in any case. Default: single."})
}
fn dxf_join<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
match Option::<Value>::deserialize(d)? {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(join)) => Ok(Some(join)),
Some(_) => Err(D::Error::custom(whole("`join` must be true or false"))),
}
}
/// What `sketch.export_dxf` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportDxfResult {
#[serde(flatten)]
pub exported: DxfExported,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Where the DXF went.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum DxfExported {
/// With a `path`: the file was written.
Written {
/// The file written, as given.
path: String,
/// How many entities it holds.
entities: usize,
},
/// Without one: the DXF as text.
Text {
/// The DXF file's text.
dxf: String,
/// How many entities it holds.
entities: usize,
},
}
#[cfg(test)]
#[path = "sketch_import_tests.rs"]
mod tests;
@@ -0,0 +1,313 @@
use serde_json::{Value, json};
use solidcraft_io::MAX_DRAWING_BYTES;
use super::{DrawingInserted, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
/// A 30 x 20 rectangle as DXF lines.
const DXF: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n30\n21\n0\n0\nLINE\n10\n30\n20\n0\n11\n30\n21\n20\n0\nLINE\n10\n30\n20\n20\n11\n0\n21\n20\n0\nLINE\n10\n0\n20\n20\n11\n0\n21\n0\n0\nENDSEC\n0\nEOF\n";
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10"><rect x="0" y="0" width="20" height="10"/><text x="2" y="5" font-size="3">Hi</text></svg>"#;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-12508-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn schemas_and_the_key_check_agree() {
let insert = ["align", "angle", "at", "height", "name", "path", "plane", "scale", "sketch", "text", "tolerance", "width"];
assert_keys(input_schema_for::<InsertDxfParams>(), &insert);
assert_keys(input_schema_for::<InsertSvgParams>(), &insert);
assert_keys(input_schema_for::<ExportDxfParams>(), &["join", "layer", "layers", "path", "sketch", "units"]);
}
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `path` and
/// `text` included: the handler's words, not "is required"). The session has a sketch being edited.
#[test]
fn strict_keeps_todays_errors() {
let too_big = "x".repeat(MAX_DRAWING_BYTES + 1);
let mut cases: Vec<(&str, Value)> = vec![
("sketch.insert_dxf", json!({})),
("sketch.insert_dxf", json!({"path": ""})),
("sketch.insert_dxf", json!({"path": " "})),
("sketch.insert_dxf", json!({"path": 5})),
("sketch.insert_dxf", json!({"path": null})),
("sketch.insert_dxf", json!({"path": "x".repeat(4096)})),
("sketch.insert_dxf", json!({"text": too_big})),
("sketch.insert_svg", json!({})),
("sketch.insert_svg", json!({"path": " "})),
("sketch.insert_svg", json!({"text": null})),
("sketch.export_dxf", json!({"sketch": true})),
("sketch.export_dxf", json!({"sketch": [1]})),
("sketch.export_dxf", json!({"sketch": -1})),
("sketch.export_dxf", json!({"sketch": 1.5})),
];
for (id, text) in [("sketch.insert_dxf", DXF), ("sketch.insert_svg", SVG)] {
for extra in [
json!({"width": 0}),
json!({"width": -2}),
json!({"width": null}),
json!({"width": "wide"}),
json!({"width": [1]}),
json!({"height": 0}),
json!({"height": null}),
json!({"height": true}),
json!({"angle": "x"}),
json!({"angle": null}),
json!({"angle": {}}),
json!({"align": "left"}),
json!({"align": "Center"}),
json!({"align": ""}),
json!({"sketch": true}),
json!({"sketch": null, "width": 0}),
json!({"sketch": [1]}),
json!({"sketch": 1.5}),
] {
let mut p = json!({"text": text});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
cases.push((id, p));
}
}
assert_strict_keeps_todays_errors(&cases);
}
/// The same refusals for a sketch that is named but doesn't exist, a width the drawing can't
/// give (a drawing with no width to size is refused by the handler alone), and the export's
/// options, which the handler reads only once a sketch is being edited.
#[test]
fn strict_keeps_the_handlers_own_refusals() {
// A vertical line has no width to size.
let vertical = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n0\n21\n9\n0\nENDSEC\n0\nEOF\n";
for (id, p) in [
("sketch.insert_dxf", json!({"text": DXF, "sketch": "Nope"})),
("sketch.insert_dxf", json!({"text": DXF, "sketch": 9999})),
("sketch.insert_dxf", json!({"text": vertical, "width": 10})),
("sketch.insert_dxf", json!({"text": "0\nEOF\n"})),
("sketch.export_dxf", json!({"sketch": "Nope"})),
("sketch.export_dxf", json!({"sketch": 9999})),
("sketch.export_dxf", json!({"units": 5})),
("sketch.export_dxf", json!({"units": "Yard"})),
("sketch.export_dxf", json!({"layer": 1})),
("sketch.export_dxf", json!({"layers": "all"})),
("sketch.export_dxf", json!({"layers": true})),
("sketch.export_dxf", json!({"join": "yes"})),
] {
let error = |level: ParamCheck| {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute(id, &p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{id} {p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
}
}
/// The types read each key as the handler does: keys it never reads in a call are not read by the
/// contract either, and the ones it reads fail with its words.
#[test]
fn the_types_read_like_the_handler() {
let reads = |p: Value| serde_json::from_value::<InsertDxfParams>(p).is_ok();
let issues = |p: Value| crate::schema::issues("sketch.insert_svg", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
// `path` is not read when `text` is given; `height` not when `width` is; `scale` not when
// either is.
assert!(reads(json!({"text": "x", "path": 5})) && reads(json!({"text": "x", "path": ""})));
assert!(reads(json!({"width": 5, "height": "x", "scale": "y"})) && reads(json!({"width": 5, "height": null})));
assert!(reads(json!({"height": 5, "scale": "y"})) && reads(json!({"height": 5, "scale": null})));
assert!(reads(json!({"path": "a.dxf", "scale": 2, "tolerance": 0.01, "at": [1, 2], "angle": 90, "align": "center"})));
assert!(reads(json!({"text": "x", "at": {"x": 1, "y": 2}, "angle": 720.5, "width": 1e12, "scale": 0, "tolerance": -1})));
// They are read when the handler reads them.
// Stricter than the handler, which reads a `text` that isn't text as left out.
assert!(!reads(json!({"text": 5, "path": "a.dxf"})) && !reads(json!({"path": "a.dxf", "scale": "y"})));
assert!(!reads(json!({"path": ""})) && !reads(json!({"path": 5, "scale": 1})));
assert!(!reads(json!({"text": "x", "height": 0})) && !reads(json!({"text": "x", "height": "x", "scale": 2})));
assert!(!reads(json!({"text": "x", "width": null, "height": 5})) && !reads(json!({"text": "x", "scale": "y", "width": 0})));
// Only while no sketch is being edited does `plane` or `name` matter, which the check can't
// see: they take anything.
assert!(reads(json!({"text": "x", "plane": 5, "name": [1]})) && reads(json!({"text": "x", "plane": null, "name": null})));
assert_eq!(issues(json!({"text": "x", "width": 0})), ["`width` must be a positive number (mm)"]);
assert_eq!(issues(json!({"text": "x", "height": -1})), ["`height` must be a positive number (mm)"]);
assert_eq!(issues(json!({"text": "x", "angle": "1"})), ["`angle` must be a number (degrees)"]);
assert_eq!(issues(json!({"text": "x", "align": "left"})), ["`align` must be \"origin\" or \"center\""]);
assert_eq!(issues(json!({"path": " "})), ["give `path` or `text`"]);
assert_eq!(issues(json!({"text": "x", "sketch": true})), ["`sketch` must be an id or a name"]);
assert_eq!(issues(json!({"text": "x", "wdth": 5})), ["unknown key `wdth`; did you mean `width`?"]);
assert!(issues(json!({"text": "x", "width": 5, "height": "x", "path": 5})).is_empty());
let export = |p: Value| serde_json::from_value::<ExportDxfParams>(p).is_ok();
assert!(export(json!({})) && export(json!({"sketch": null, "path": null})) && export(json!({"sketch": "S", "path": ""})));
assert!(export(json!({"sketch": 3, "path": "a.dxf"})) && !export(json!({"sketch": true})) && !export(json!({"path": 5})));
}
/// What the schemas take: a drawing from `path` or `text`, and the keys' own shapes.
#[test]
fn the_schemas_take_what_the_handlers_read() {
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
let schema = schema.as_value();
for (p, takes) in cases {
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
}
};
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
takes(
schema,
&[
(json!({"text": "x"}), true),
(json!({"path": "a.svg", "at": [1, 2], "scale": 2, "angle": 45, "align": "center", "tolerance": 0.01}), true),
(json!({"text": "x", "width": 40, "plane": "XZ", "name": "Logo", "sketch": "Sketch1"}), true),
(json!({"text": "x", "plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "sketch": 4}), true),
(json!({}), false),
(json!({"width": 40}), false),
(json!({"text": "x", "width": 0}), false),
(json!({"text": "x", "height": -1}), false),
(json!({"text": "x", "angle": "x"}), false),
(json!({"text": "x", "align": "left"}), false),
(json!({"text": "x", "at": [1]}), false),
(json!({"text": "x", "wdth": 5}), false),
(json!({"path": "x".repeat(4096)}), false),
(json!({"text": "x", "path": " "}), true),
// Unread keys take anything, as in the handler; read ones keep their shapes.
(json!({"text": "x", "path": 5}), true),
(json!({"text": "x", "width": 5, "height": -1}), true),
(json!({"text": "x", "width": 5, "scale": "y"}), true),
(json!({"text": "x", "height": 5, "scale": "y"}), true),
(json!({"path": 5}), false),
(json!({"text": "x", "height": "x", "scale": 2}), false),
(json!({"text": "x", "scale": "y"}), false),
],
);
}
// A `path` read without `text` is not blank (`conforms` doesn't check patterns).
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
let read_path = &schema.as_value()["anyOf"][1]["properties"]["path"];
assert_eq!(read_path["pattern"], json!(crate::schema::types::not_blank_pattern()));
}
takes(
input_schema_for::<ExportDxfParams>(),
&[
(json!({}), true),
(json!({"sketch": "S", "path": "a.dxf"}), true),
(json!({"sketch": 2}), true),
(json!({"sketch": true}), false),
(json!({"file": "a.dxf"}), false),
],
);
}
fn matches(schema: schemars::Schema, r: &Value) {
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
}
/// Every form of the three commands replies as its output schema says, in Warn without warnings;
/// the replies carry the keys they always had; a misspelt key gets its warning and the reply still
/// matches, and strict refuses it.
#[test]
fn replies_match_the_output_schema() {
let dir = temp_dir("replies");
let svg_file = dir.join("plate.svg");
let dxf_file = dir.join("plate.dxf");
std::fs::write(&svg_file, SVG).unwrap();
std::fs::write(&dxf_file, DXF).unwrap();
let (svg_path, dxf_path) = (svg_file.to_string_lossy().into_owned(), dxf_file.to_string_lossy().into_owned());
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let run = |s: &mut Session, id: &str, p: Value| {
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
r
};
// The session adds `created` (the ids a sketch command made) to every `sketch.*` reply, outside
// the handlers; it is not part of what they build.
let keys = |r: &Value| {
r.as_object().map(|o| o.keys().filter(|k| *k != "created").cloned().collect::<std::collections::BTreeSet<_>>()).unwrap_or_default()
};
let insert_keys: std::collections::BTreeSet<String> = ["created_sketch", "curves", "info", "sketch", "texts"].map(String::from).into();
// With no sketch being edited: a new one is made, on the plane and with the name given.
let r = run(&mut s, "sketch.insert_dxf", json!({"text": DXF, "plane": "XZ", "name": "Plate", "at": [5, 5]}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!(keys(&r), insert_keys, "{r}");
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(0)), "{r}");
assert_eq!(r["created_sketch"], r["sketch"], "{r}");
// Exactly what the handler returned before it built its reply from the type.
let mut handler_reply = r.clone();
handler_reply.as_object_mut().unwrap().remove("created");
assert_eq!(
handler_reply,
json!({"sketch": r["sketch"], "created_sketch": r["sketch"], "curves": 4, "texts": 0, "info": {"solved": true, "dof": 8}})
);
assert!(r["info"]["solved"].is_boolean() && r["info"]["dof"].is_u64(), "{r}");
// With one: into it, and `plane` and `name` (of any kind) are not read.
let id = s.active_sketch.unwrap();
for p in [
json!({"text": DXF, "path": 5, "plane": 7, "name": [1]}),
json!({"text": DXF, "width": 60, "height": "x", "scale": "y", "angle": 90, "align": "center", "at": {"x": 1, "y": 2}}),
json!({"text": DXF, "height": 10, "scale": "y", "tolerance": 0.01}),
json!({"text": DXF, "scale": 2, "align": "origin", "sketch": id}),
json!({"text": DXF, "sketch": s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default()}),
json!({"path": dxf_path, "text": null, "sketch": null}),
] {
let r = run(&mut s, "sketch.insert_dxf", p);
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!(keys(&r), insert_keys);
assert_eq!((r["sketch"].as_u64(), r["created_sketch"].clone(), r["curves"].as_u64()), (Some(id), Value::Null, Some(4)), "{r}");
}
let r = run(&mut s, "sketch.insert_svg", json!({"text": SVG, "angle": 15}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(1)), "{r}");
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "width": 40, "align": "center", "at": [10, 10]}));
assert_eq!(r["texts"], 1, "{r}");
s.execute("sketch.finish", &json!({})).unwrap();
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "plane": "YZ"}));
matches(output_schema_for::<DrawingInserted>(), &r);
assert_ne!(r["created_sketch"], Value::Null, "{r}");
s.execute("sketch.finish", &json!({})).unwrap();
// Export: the text, or a file.
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id}));
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
assert!(r["dxf"].as_str().is_some_and(|t| t.contains("ENTITIES")), "{r}");
assert!(r["entities"].as_u64().is_some_and(|n| n >= 4), "{r}");
let out = dir.join("out.dxf");
let out_path = out.to_string_lossy().into_owned();
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id, "path": out_path}));
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(r["path"], json!(out_path), "{r}");
assert_eq!(keys(&r), ["entities", "path"].map(String::from).into(), "{r}");
assert!(std::fs::read_to_string(&out).unwrap().contains("ENTITIES"));
// A blank `path` and a null `sketch` read as left out.
s.execute("sketch.edit", &json!({"sketch": id})).unwrap();
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": null, "path": " "}));
assert!(r.get("dxf").is_some(), "{r}");
matches(output_schema_for::<ExportDxfResult>(), &r);
// The units and layer options, in any case; null and a blank layer read as left out.
for p in [
json!({"units": "IN", "layer": "PART"}),
json!({"units": "design", "layers": "Roles"}),
json!({"join": true, "layer": " "}),
json!({"units": null, "layer": null, "layers": null, "join": null}),
] {
let r = run(&mut s, "sketch.export_dxf", p);
matches(output_schema_for::<ExportDxfResult>(), &r);
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
}
let _ = std::fs::remove_dir_all(&dir);
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
let p = json!({"text": DXF, "wdth": 5});
let r = s.execute("sketch.insert_dxf", &p).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`wdth`")))), "{r}");
matches(output_schema_for::<DrawingInserted>(), &r);
let r = s.execute("sketch.export_dxf", &json!({"pth": "a.dxf"})).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
matches(output_schema_for::<ExportDxfResult>(), &r);
let r = s.execute("sketch.insert_svg", &json!({"text": SVG, "angel": 5})).unwrap();
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
s.param_check = ParamCheck::Strict;
let error = s.execute("sketch.insert_dxf", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(error.contains("`wdth`") && error.contains("did you mean `width`"), "{error}");
}
+30 -3
View File
@@ -154,6 +154,15 @@ fn read_link_flag(o: &Map<String, Value>) -> Result<Option<bool>, String> {
}
}
/// `construction`: a boolean (`null` is refused too); left out, the session's preference decides.
fn read_construction(o: &Map<String, Value>) -> Result<Option<bool>, String> {
match o.get("construction") {
None => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err("`construction` must be a boolean".into()),
}
}
/// `refs` and `ref` are one of them required: left out, the handler refuses with its own message.
fn refs_required(schema: &mut Schema) {
schema.insert("anyOf".into(), json!([{"required": ["refs"]}, {"required": ["ref"]}]));
@@ -180,6 +189,11 @@ pub struct ProjectParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub projection_link: Option<bool>,
/// Make the created curves construction geometry (dashed, left out of profiles; they stay
/// linked). Default: the "Project references as construction geometry" preference, off.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub construction: Option<bool>,
}
impl<'de> Deserialize<'de> for ProjectParams {
@@ -188,7 +202,8 @@ impl<'de> Deserialize<'de> for ProjectParams {
// The handler reads `projection_link` before the references.
let projection_link = read_link_flag(&o).map_err(|why| D::Error::custom(whole(why)))?;
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
Ok(ProjectParams { refs, reference, projection_link })
let construction = read_construction(&o).map_err(|why| D::Error::custom(whole(why)))?;
Ok(ProjectParams { refs, reference, projection_link, construction })
}
}
@@ -212,6 +227,11 @@ pub struct IntersectParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub projection_link: Option<bool>,
/// Make the created curves construction geometry (dashed, left out of profiles; they stay
/// linked). Default: the "Project references as construction geometry" preference, off.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub construction: Option<bool>,
}
impl<'de> Deserialize<'de> for IntersectParams {
@@ -219,7 +239,8 @@ impl<'de> Deserialize<'de> for IntersectParams {
let o = Map::<String, Value>::deserialize(d)?;
let projection_link = read_link_flag(&o).map_err(|why| D::Error::custom(whole(why)))?;
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
Ok(IntersectParams { refs, reference, projection_link })
let construction = read_construction(&o).map_err(|why| D::Error::custom(whole(why)))?;
Ok(IntersectParams { refs, reference, projection_link, construction })
}
}
@@ -237,13 +258,19 @@ pub struct Include3dParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(rename = "ref", with = "ProjectRefs")]
pub reference: Option<ProjectRefs>,
/// Make the created curves construction geometry (dashed, left out of profiles; they stay
/// linked). Default: the "Project references as construction geometry" preference, off.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub construction: Option<bool>,
}
impl<'de> Deserialize<'de> for Include3dParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let o = Map::<String, Value>::deserialize(d)?;
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
Ok(Include3dParams { refs, reference })
let construction = read_construction(&o).map_err(|why| D::Error::custom(whole(why)))?;
Ok(Include3dParams { refs, reference, construction })
}
}
@@ -40,9 +40,9 @@ fn error(level: ParamCheck, id: &str, p: &Value) -> Option<String> {
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<ProjectParams>(), &["projection_link", "ref", "refs"]);
assert_keys(input_schema_for::<IntersectParams>(), &["projection_link", "ref", "refs"]);
assert_keys(input_schema_for::<Include3dParams>(), &["ref", "refs"]);
assert_keys(input_schema_for::<ProjectParams>(), &["construction", "projection_link", "ref", "refs"]);
assert_keys(input_schema_for::<IntersectParams>(), &["construction", "projection_link", "ref", "refs"]);
assert_keys(input_schema_for::<Include3dParams>(), &["construction", "ref", "refs"]);
assert_keys(input_schema_for::<ProjectToSurfaceParams>(), &["body", "curves", "face"]);
assert_keys(input_schema_for::<IntersectionCurveParams>(), &["a", "b"]);
assert_keys(input_schema_for::<SpunProfileParams>(), &["axis", "body"]);
+979
View File
@@ -0,0 +1,979 @@
//! Contracts and result types for the sketch session commands in `cmd/sketch.rs`:
//! `sketch.create`, `sketch.construction`, `sketch.delete`, `sketch.move_point`, `sketch.drag`,
//! `sketch.drag_end`, `sketch.dimension_text`, `sketch.options`, `sketch.chain` and
//! `sketch.solve` (#13224).
//!
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
//! reads it and fails with the handler's message. Whether a sketch, a curve, a point or a
//! dimension exists stays the handler's to check, and so does everything that needs the design.
//! A key the handler refuses when it is left out is optional in the type and required in the
//! schema, so that the handler's message, not the check's "is required", is what a caller sees.
//!
//! Some handlers read a key only in some cases, and the types read it only there too, so a call
//! that works today gets no warning:
//!
//! - `sketch.create` reads `project_edges` only when `plane` is a picked face, and `frame` is
//! for an explicit plane only. It refuses `face`, `origin`, `x_dir` and `y_dir` next to
//! `plane` with a message that says where they belong, so they are listed (and can never be
//! used) to keep that message in strict;
//! - `sketch.dimension_text` reads `at` only without `reset: true`;
//! - `sketch.drag_end` reads a constraint's `command` and `params` only when `constraint` is not
//! `null`.
//!
//! A call with `live: true` of `sketch.drag` goes to the live runner, which the check doesn't
//! wrap, so live frames are not checked yet.
//!
//! `sketch.options` refuses a key that is not one of its options with "unknown option"; the
//! check names it first ("unknown key", with a suggestion) in warn and strict.
use std::borrow::Cow;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value, json};
use super::canvas::CanvasPlane;
use super::check::{located, whole};
use super::frame::SketchSolve;
use super::motion::require;
use super::sketch_ends::SketchTarget;
use super::types::{LengthExpr, Point2, present};
use crate::params;
/// The words of `sketch.create`'s refusals, as `plane_frame` and `flip_param` give them.
const FRAME_FORMS: &str = "`frame` must be \"world\" or \"component\"";
const FRAME_NOT_EXPLICIT: &str =
"`frame` is for an explicit plane {origin, x_dir, y_dir}: a named plane is the component's own, and a face is where it is";
const FLIP_FORMS: &str = "`flip` must be true, false, \"x\" or \"y\"";
const FLIP_ON_FACE: &str = "`flip` is for origin, construction and explicit planes; a face already faces outward";
const ACTIVATE_FORMS: &str = "`activate_part` must be true or false";
/// The keys `sketch.create` refuses next to `plane`, and the form each belongs in.
const MISPLACED: [(&str, &str); 4] = [
("face", r#"{"plane": {"face": [x, y, z]}}"#),
("origin", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
("x_dir", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
("y_dir", r#"{"plane": {"origin": [x, y, z], "x_dir": [..], "y_dir": [..]}}"#),
];
/// The message of a handler's refusal.
fn message(e: crate::EngineError) -> String {
match e {
crate::EngineError::BadParams { msg, .. } => msg,
other => other.to_string(),
}
}
/// The handler's message for a value that is not what a key needs, kept whole.
fn refuse<E: serde::de::Error>(message: impl std::fmt::Display) -> E {
E::custom(whole(message))
}
// ---------------------------------------------------------------------------------------------
// Shared readers
/// `key` as text, the handler's `str_`-and-refuse: a string, or `message`.
macro_rules! text_key {
($($name:ident: $message:literal),+ $(,)?) => {$(
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(Some(s)),
_ => Err(refuse($message)),
}
}
)+};
}
text_key!(
point_id: "`point` must be a point reference",
entity_id: "`entity` must be a sketch point or curve",
curve_id: "`curve` must be a curve id",
dimension_id: "`dimension` must be a dimension id or parameter name",
);
/// A required point `[x, y]` as `req_vec2` reads it (`null` is refused).
macro_rules! point_key {
($($name:ident: $key:literal),+ $(,)?) => {$(
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Point2>, D::Error> {
params::vec2(&Value::deserialize(d)?)
.map(|p| Some(Point2(p)))
.ok_or_else(|| refuse(concat!("`", $key, "` must be a point [x, y]")))
}
)+};
}
point_key!(to_point: "to", at_point: "at");
/// `from` of `sketch.drag`: a point, where `null` is read as left out (the grab is at `to`).
fn from_point<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Point2>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
v => params::vec2(&v).map(|p| Some(Point2(p))).ok_or_else(|| refuse("`from` must be a point [x, y]")),
}
}
/// Entity ids, as `params::string_list` reads a list of names: a list of text, or one text. An
/// empty list, `null` included, is the handler's own refusal (`empty`).
#[derive(Clone, Debug, PartialEq)]
pub struct EntityIds(pub Vec<String>);
fn entity_ids<'de, D: Deserializer<'de>>(d: D, key: &str, empty: &str) -> Result<Option<EntityIds>, D::Error> {
let mut given = Map::new();
given.insert(key.to_string(), Value::deserialize(d)?);
let list = params::string_list("", &Value::Object(given), key).map_err(|e| refuse::<D::Error>(message(e)))?;
if list.is_empty() { Err(refuse(empty)) } else { Ok(Some(EntityIds(list))) }
}
fn curves<'de, D: Deserializer<'de>>(d: D) -> Result<Option<EntityIds>, D::Error> {
entity_ids(d, "curves", "`curves` must list curve ids")
}
fn entities<'de, D: Deserializer<'de>>(d: D) -> Result<Option<EntityIds>, D::Error> {
entity_ids(d, "entities", "`entities` must list sketch entity ids")
}
impl JsonSchema for EntityIds {
fn schema_name() -> Cow<'static, str> {
"SketchEntityIds".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Ids, as a list of text (at least one, at most 10000) or a single id.",
"anyOf": [
{"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": params::MAX_STRING_LIST},
{"type": "string"}
]
})
}
}
/// The ids of what a drag takes along: a list of text, as the handler reads `with`.
#[derive(Clone, Debug, PartialEq)]
pub struct DragWith(pub Vec<String>);
fn with_ids<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DragWith>, D::Error> {
const FORMS: &str = "`with` must be a list of sketch point or curve ids";
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::Array(ids) => ids
.into_iter()
.map(|v| if let Value::String(id) = v { Some(id) } else { None })
.collect::<Option<Vec<_>>>()
.map(|ids| Some(DragWith(ids)))
.ok_or_else(|| refuse(FORMS)),
_ => Err(refuse(FORMS)),
}
}
impl JsonSchema for DragWith {
fn schema_name() -> Cow<'static, str> {
"DragWith".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Point and curve ids, as a list. No more than the sketch has points and curves together.",
"type": "array",
"items": {"type": "string"}
})
}
}
/// An optional flag the handler reads with `bool_`, which takes a wrong type as absent: `null` is
/// absent, and any other value that is not true or false is refused here.
fn flag<'de, D: Deserializer<'de>>(d: D, key: &str) -> Result<Option<bool>, D::Error> {
match Value::deserialize(d)? {
Value::Null => Ok(None),
Value::Bool(b) => Ok(Some(b)),
_ => Err(refuse(format!("`{key}` must be true or false"))),
}
}
fn live<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag(d, "live")
}
fn reset<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag(d, "reset")
}
/// One flag each, as the handler reads them (see [`flag`]).
macro_rules! option_flags {
($($name:ident),+ $(,)?) => {$(
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
flag(d, stringify!($name))
}
)+};
}
option_flags!(value, show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d);
// ---------------------------------------------------------------------------------------------
// sketch.create
/// A plane for a new sketch, as `sketch.create` reads `plane`: XY, XZ or YZ (or Top, Front or
/// Right), a construction plane by name or id, a picked planar face, or an explicit plane. Kept
/// as given, for the handler.
#[derive(Clone, Debug, PartialEq)]
pub struct SketchPlane(pub Value);
impl<'de> Deserialize<'de> for SketchPlane {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
CanvasPlane::deserialize(d).map(|plane| SketchPlane(plane.0))
}
}
impl JsonSchema for SketchPlane {
fn schema_name() -> Cow<'static, str> {
"SketchPlane".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
CanvasPlane::json_schema(generator)
}
}
/// `flip`: `true`, `false`, or the axis (`"x"` or `"y"`, any case) to turn the plane about.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Flip {
Flag(bool),
AboutX,
AboutY,
}
impl<'de> Deserialize<'de> for Flip {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::Bool(b) => Ok(Flip::Flag(b)),
Value::String(a) if a.eq_ignore_ascii_case("x") => Ok(Flip::AboutX),
Value::String(a) if a.eq_ignore_ascii_case("y") => Ok(Flip::AboutY),
_ => Err(refuse(FLIP_FORMS)),
}
}
}
impl JsonSchema for Flip {
fn schema_name() -> Cow<'static, str> {
"Flip".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "false keeps the plane's own side; true turns it about its y axis (about x when y does not point upward) so the sketch's y axis stays upward; \"x\" or \"y\" (any case) turns it about that axis of the plane.",
"anyOf": [{"type": "boolean"}, {"type": "string", "pattern": "^[xXyY]$"}]
})
}
}
impl Flip {
/// Whether the plane is turned at all.
fn turns(self) -> bool {
self != Flip::Flag(false)
}
}
/// Which coordinates an explicit plane is in: `world` (the default) or the active component's
/// own, `component`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Frame {
// World coordinates (the default); the component's own coordinates.
World,
Component,
}
impl Frame {
/// The frame `plane_frame` names.
pub fn named(name: &str) -> Option<Frame> {
match name {
"world" => Some(Frame::World),
"component" => Some(Frame::Component),
_ => None,
}
}
}
/// A key `sketch.create` refuses next to `plane`: it belongs inside `plane`.
#[derive(Debug)]
pub struct InsidePlane;
impl<'de> Deserialize<'de> for InsidePlane {
fn deserialize<D: Deserializer<'de>>(_: D) -> Result<Self, D::Error> {
Err(refuse("this key belongs inside `plane`"))
}
}
impl JsonSchema for InsidePlane {
fn schema_name() -> Cow<'static, str> {
"InsidePlane".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Not taken here, in any form: the call is refused, and the message says where it belongs. Put it inside `plane`.",
"not": {}
})
}
}
/// The keys of a `sketch.create` call.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct CreateKeys {
/// The plane to sketch on: XY, XZ or YZ (also Top, Front, Right), a construction plane's
/// name or id, a picked planar face `{face: [x, y, z], body?, index?, path?, fixed?}`, or an
/// explicit plane `{origin?, x_dir, y_dir}` in mm. Default: XY. On XY, the sketch's x and y
/// run along world +X and +Y; on XZ, +X and -Z; on YZ, +Y and +Z. A face of a sheet metal
/// body attaches to its panel instead, in the flat pattern's coordinates; `fixed: true` keeps
/// the plane where the face is now. A named plane is the active component's own.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "SketchPlane")]
pub plane: Option<SketchPlane>,
/// Move the plane this far along its normal (mm or a length expression), before `flip`.
#[serde(default)]
pub offset: Option<LengthExpr>,
/// Not on a face: sketch the plane from its other side, so the normal and the default
/// extrude direction are reversed. Default: false.
#[serde(default)]
pub flip: Option<Flip>,
/// Which coordinates an explicit plane is in. Default: world. Only for an explicit plane
/// `{origin, x_dir, y_dir}`; the reply's `frame` says which, with a `note` when the active
/// component is moved or turned.
#[serde(default)]
pub frame: Option<Frame>,
/// Only on a picked face: project the face's edges into the sketch. Default: the session's
/// Auto Project setting.
#[serde(default)]
pub project_edges: Option<bool>,
/// Only on a face of a body of another component than the active one: activate that
/// component at the picked placement first, as `component.activate` would, and make the
/// sketch there. It stays active afterwards and the reply's `activated` names it. Default:
/// false.
#[serde(default)]
pub activate_part: Option<bool>,
/// The sketch's name. Default: numbered.
#[serde(default)]
pub name: Option<String>,
/// Refused: it belongs inside `plane`.
#[serde(default)]
#[schemars(with = "InsidePlane")]
pub face: Option<InsidePlane>,
/// Refused: it belongs inside `plane`.
#[serde(default)]
#[schemars(with = "InsidePlane")]
pub origin: Option<InsidePlane>,
/// Refused: it belongs inside `plane`.
#[serde(default)]
#[schemars(with = "InsidePlane")]
pub x_dir: Option<InsidePlane>,
/// Refused: it belongs inside `plane`.
#[serde(default)]
#[schemars(with = "InsidePlane")]
pub y_dir: Option<InsidePlane>,
}
/// `sketch.create`'s parameters: [`CreateKeys`], read in the handler's order, and `project_edges`
/// only for a face, as the handler does.
#[derive(Debug)]
pub struct CreateParams(pub CreateKeys);
impl<'de> Deserialize<'de> for CreateParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
for (key, example) in MISPLACED {
if value.get(key).is_some() {
return Err(refuse(format!("`{key}` belongs inside `plane`: {example}")));
}
}
if !matches!(value.get("activate_part"), None | Some(Value::Null | Value::Bool(_))) {
return Err(refuse(ACTIVATE_FORMS));
}
let face = value.get("plane").and_then(|plane| plane.get("face")).is_some();
let explicit = value.get("plane").is_some_and(Value::is_object) && !face;
let frame = match value.get("frame") {
None | Some(Value::Null) => false,
Some(Value::String(f)) if Frame::named(f).is_some() => true,
Some(_) => return Err(refuse(FRAME_FORMS)),
};
if frame && !explicit {
return Err(refuse(FRAME_NOT_EXPLICIT));
}
if let Some(plane) = value.get("plane") {
SketchPlane::deserialize(plane).map_err(|e| D::Error::custom(e.to_string()))?;
}
match value.get("flip") {
None | Some(Value::Null) => {}
Some(flip) => {
let flip = Flip::deserialize(flip).map_err(|e| D::Error::custom(e.to_string()))?;
if flip.turns() && face {
return Err(refuse(FLIP_ON_FACE));
}
}
}
// `project_edges` is read only for a picked face.
if !face && let Some(keys) = value.as_object_mut() {
keys.remove("project_edges");
}
serde_path_to_error::deserialize(&value).map(CreateParams).map_err(|e| D::Error::custom(located(&e)))
}
}
impl JsonSchema for CreateParams {
fn schema_name() -> Cow<'static, str> {
"CreateParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
CreateKeys::json_schema(generator)
}
}
/// How a new sketch is attached to its plane: `exact_face`, `sheet_face`, `internal_legacy_face`
/// or `fixed`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Attachment {
// `exact_face`: a planar face of a body, exactly, so the sketch follows the face;
// `sheet_face`: the panel of a sheet metal body's flat pattern;
// `internal_legacy_face`: a face picked by a command that starts a sketch on a face
// (Extrude, Revolve, Press Pull);
// `fixed`: where the plane is (an origin, construction or explicit plane, or a face kept
// with `fixed`).
ExactFace,
SheetFace,
InternalLegacyFace,
Fixed,
}
/// The component `activate_part` made active.
#[derive(Debug, Serialize, JsonSchema)]
pub struct Activated {
/// The component, by id.
pub component: u64,
/// The placement's occurrence path (empty at the root).
pub path: Vec<u64>,
}
/// What `sketch.create` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CreateResult {
/// The new sketch's feature id.
pub sketch: u64,
/// Its name.
pub name: String,
/// On a face, the link id of the face's projected edges; null when none were projected.
pub projected: Option<String>,
/// How the sketch is attached to its plane.
pub attachment: Option<Attachment>,
/// For an explicit plane: which coordinates it was read in.
#[serde(skip_serializing_if = "Option::is_none")]
pub frame: Option<Frame>,
/// With a world `frame`, when the active component is moved or turned: that the plane was
/// stored at its world place in the component.
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// With `activate_part`, when it made another component active.
#[serde(skip_serializing_if = "Option::is_none")]
pub activated: Option<Activated>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
// ---------------------------------------------------------------------------------------------
// sketch.construction, sketch.delete
fn curves_required(schema: &mut Schema) {
require(schema, "curves");
}
fn entities_required(schema: &mut Schema) {
require(schema, "entities");
}
/// Switch curves between normal and construction. The selection switches together (a slot or
/// polygon as a whole): to construction unless all of it already is. Projected curves may
/// switch too. A centerline that becomes construction stops being a centerline, and a normal
/// line is a finite one. One undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = curves_required)]
pub struct ConstructionParams {
// Optional in the type and required in the schema (`curves_required`).
/// The curves, by id.
#[serde(default, deserialize_with = "curves", skip_serializing_if = "Option::is_none")]
#[schemars(with = "EntityIds")]
pub curves: Option<EntityIds>,
/// True makes them construction, false makes them normal. Default: construction unless all of
/// them already are.
#[serde(default, deserialize_with = "value", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub value: Option<bool>,
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// What `sketch.construction` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ConstructionResult {
/// How many curves changed.
pub changed: usize,
/// Whether they are construction now.
pub construction: bool,
/// The sketch's solve; null when it was not solved again.
pub sketch: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Delete curves, points and constraints (dimensions too) from a sketch, by id; a sketch's
/// wires and linked geometry too. The sketch origin cannot be deleted. One undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = entities_required)]
pub struct DeleteParams {
// Optional in the type and required in the schema (`entities_required`).
/// What to delete: curve, point, constraint, wire or link ids.
#[serde(default, deserialize_with = "entities", skip_serializing_if = "Option::is_none")]
#[schemars(with = "EntityIds")]
pub entities: Option<EntityIds>,
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// What `sketch.delete` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DeleteResult {
/// How many ids were given (all of them were found).
pub deleted: usize,
/// The sketch's solve; null when it was not solved again.
pub sketch: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
// ---------------------------------------------------------------------------------------------
// sketch.move_point, sketch.drag, sketch.drag_end
fn move_point_required(schema: &mut Schema) {
require(schema, "point");
require(schema, "to");
}
/// Move a sketch point to a position and solve the sketch within its constraints: the point
/// goes as far toward it as they allow. One undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = move_point_required)]
pub struct MovePointParams {
// `point` and `to` are optional in the type and required in the schema
// (`move_point_required`).
/// The point, by id (`p3`, `l1.end`, `origin`).
#[serde(default, deserialize_with = "point_id", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub point: Option<String>,
/// Where to move it, in sketch coordinates (mm).
#[serde(default, deserialize_with = "to_point", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point2")]
pub to: Option<Point2>,
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// What `sketch.move_point` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct MovePointResult {
/// The sketch's solve; null when it was not solved again.
pub sketch: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
fn drag_required(schema: &mut Schema) {
require(schema, "entity");
require(schema, "to");
}
/// Drag a sketch point or curve from where it was grabbed to a position: points and the
/// material point grabbed on a line move toward it within the design constraints, and when exact
/// placement is impossible, bounded feasible steps minimize the cursor distance. Splines slide
/// by `to` minus `from`; a circle or arc grabbed on its edge takes the radius to `to`. Long
/// moves keep the solution branch, and arcs cannot sweep through their starts. With
/// `live: true` (one frame of a drag; such calls are not checked against this schema yet), later
/// features stay stale until `sketch.drag_end`, and `dof` stays from before the drag.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = drag_required)]
pub struct DragParams {
// `entity` and `to` are optional in the type and required in the schema (`drag_required`).
/// The point or curve to drag, by id.
#[serde(default, deserialize_with = "entity_id", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub entity: Option<String>,
/// Where it was grabbed, in sketch coordinates (mm). Default: `to`.
#[serde(default, deserialize_with = "from_point", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<Point2>")]
pub from: Option<Point2>,
/// Where it is dragged to, in sketch coordinates (mm).
#[serde(default, deserialize_with = "to_point", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point2")]
pub to: Option<Point2>,
/// Other points and curves that move with the entity, as one translation (circles keep
/// their radii).
#[serde(default, deserialize_with = "with_ids", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<DragWith>")]
pub with: Option<DragWith>,
/// One frame of an interactive drag: the sketch is solved and shown, and the features after
/// it stay as they were until `sketch.drag_end`. Default: false.
#[serde(default, deserialize_with = "live", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub live: Option<bool>,
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// What `sketch.drag` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DragResult {
/// The sketch's solve; null when it was not solved again.
pub sketch: Option<SketchSolve>,
/// How far the drag got: progress projected onto the requested move, 0 to 1.
pub reached: f64,
/// Where the grabbed position is now, for the next frame: [x, y].
pub at: [f64; 2],
/// Whether work stopped at its budget after making progress: another frame can continue.
pub retry: bool,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// The constraint a drag let go on, as `sketch.drag_end` reads `constraint`.
#[derive(Clone, Debug, PartialEq)]
pub struct DragConstraint {
/// A `sketch.constraint.*` command.
pub command: String,
/// Its parameters, as that command takes them. Default: none.
pub params: Option<Value>,
}
impl<'de> Deserialize<'de> for DragConstraint {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let value = Value::deserialize(d)?;
let name = value.get("command").and_then(Value::as_str).unwrap_or_default();
if !crate::cmd::find_command(name).is_some_and(|spec| spec.id.starts_with("sketch.constraint.")) {
return Err(refuse("`constraint.command` must be a sketch.constraint.* command"));
}
Ok(DragConstraint { command: name.to_string(), params: value.get("params").cloned() })
}
}
impl JsonSchema for DragConstraint {
fn schema_name() -> Cow<'static, str> {
"DragConstraint".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The constraint the drag let go on: a `sketch.constraint.*` command and its parameters. It is added when it does not over-constrain the sketch.",
"type": "object",
"properties": {
"command": {"description": "A sketch.constraint.* command id.", "type": "string", "pattern": "^sketch\\.constraint\\."},
"params": {"description": "The command's parameters."}
},
"required": ["command"]
})
}
}
/// End a drag made of `sketch.drag` frames with `live: true`: add the constraint it let go on
/// (not kept if it over-constrains the sketch), then evaluate the whole model once. Returns the
/// constraint's result and the sketch's solved state and degrees of freedom. It reads only the
/// design, not the evaluated model.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct DragEndParams {
/// The constraint to add, `{command, params}`. Default: none.
#[serde(default)]
pub constraint: Option<DragConstraint>,
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// What `sketch.drag_end` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DragEndResult {
/// What the constraint command returned; `{"error": message}` when it could not be added (it
/// is not kept); null when no constraint was given.
pub constraint: Value,
/// The sketch's solve; null when it was not solved again.
pub sketch: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
// ---------------------------------------------------------------------------------------------
// sketch.dimension_text
/// `sketch.dimension_text`'s keys.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = dimension_text_required)]
pub struct DimensionTextKeys {
// `dimension` is optional in the type and required in the schema, and so is `at` unless
// `reset` is true (`dimension_text_required`).
/// The dimension: a constraint id, or the name of the parameter it drives.
#[serde(default, deserialize_with = "dimension_id", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub dimension: Option<String>,
/// Where the text's centre goes, in sketch coordinates (mm). Not read with `reset: true`.
#[serde(default, deserialize_with = "at_point", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point2")]
pub at: Option<Point2>,
/// True puts the text back in its default place; `at` is then not read. Default: false.
#[serde(default, deserialize_with = "reset", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub reset: Option<bool>,
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
fn dimension_text_required(schema: &mut Schema) {
require(schema, "dimension");
schema.insert("anyOf".into(), json!([{"required": ["at"]}, {"required": ["reset"], "properties": {"reset": {"enum": [true]}}}]));
}
/// Move the text of a dimension to a position in the sketch, or back to its default place. The
/// text is kept relative to the dimension, so it follows the dimension when the sketch changes.
/// One undo step.
#[derive(Debug)]
pub struct DimensionTextParams(pub DimensionTextKeys);
impl<'de> Deserialize<'de> for DimensionTextParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let mut value = Value::deserialize(d)?;
// `at` is read only without `reset: true`.
if value.get("reset").and_then(Value::as_bool) == Some(true)
&& let Some(keys) = value.as_object_mut()
{
keys.remove("at");
}
serde_path_to_error::deserialize(&value).map(DimensionTextParams).map_err(|e| D::Error::custom(located(&e)))
}
}
impl JsonSchema for DimensionTextParams {
fn schema_name() -> Cow<'static, str> {
"DimensionTextParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let mut schema = DimensionTextKeys::json_schema(generator);
schema.insert(
"description".into(),
"Move the text of a dimension to a position in the sketch, or back to its default place. The text is kept relative to the dimension, so it follows the dimension when the sketch changes. One undo step."
.into(),
);
schema
}
}
/// What `sketch.dimension_text` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DimensionTextResult {
/// The dimension's constraint id.
pub dimension: String,
/// The sketch's solve; null when it was not solved again.
pub sketch: Option<SketchSolve>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
// ---------------------------------------------------------------------------------------------
// sketch.options
/// Show or hide what a sketch's palette shows, and read the options. Without any option, only
/// reads them. Changing an option is one undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct OptionsParams {
/// Show the profiles (the filled regions).
#[serde(default, deserialize_with = "show_profile", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub show_profile: Option<bool>,
/// Show the points.
#[serde(default, deserialize_with = "show_points", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub show_points: Option<bool>,
/// Show the dimensions.
#[serde(default, deserialize_with = "show_dimensions", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub show_dimensions: Option<bool>,
/// Show the constraint marks.
#[serde(default, deserialize_with = "show_constraints", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub show_constraints: Option<bool>,
/// Show projected geometry.
#[serde(default, deserialize_with = "show_projected", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub show_projected: Option<bool>,
/// Show construction geometry.
#[serde(default, deserialize_with = "show_construction", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub show_construction: Option<bool>,
/// Show the grid.
#[serde(default, deserialize_with = "grid", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub grid: Option<bool>,
/// Clicks snap to the grid.
#[serde(default, deserialize_with = "snap", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub snap: Option<bool>,
/// Cut away the model in front of the sketch plane while editing.
#[serde(default, deserialize_with = "slice", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub slice: Option<bool>,
/// Make it a 3D sketch: drawing may leave the sketch plane.
#[serde(default, deserialize_with = "sketch_3d", skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<bool>")]
pub sketch_3d: Option<bool>,
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// A sketch's palette options: true is shown (or on).
#[derive(Debug, Serialize, JsonSchema)]
pub struct SketchOptions {
/// The profiles are shown.
pub show_profile: bool,
/// The points are shown.
pub show_points: bool,
/// The dimensions are shown.
pub show_dimensions: bool,
/// The constraint marks are shown.
pub show_constraints: bool,
/// Projected geometry is shown.
pub show_projected: bool,
/// Construction geometry is shown.
pub show_construction: bool,
/// The grid is shown.
pub grid: bool,
/// Clicks snap to the grid.
pub snap: bool,
/// The model in front of the sketch plane is cut away while editing.
pub slice: bool,
/// It is a 3D sketch: drawing may leave the sketch plane.
pub sketch_3d: bool,
}
/// What `sketch.options` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct OptionsResult {
/// The options as they are, or as they are after a change; with a change, also the sketch's
/// solve.
#[serde(flatten)]
pub options: OptionsReply,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// The options as they are, or as they are after a change.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum OptionsReply {
/// No option was given: only read.
Read {
/// All the options.
options: SketchOptions,
},
/// Options were given: set.
Changed {
/// All the options, after the change.
options: SketchOptions,
/// The sketch's solve; null when it was not solved again.
sketch: Option<SketchSolve>,
},
}
// ---------------------------------------------------------------------------------------------
// sketch.chain, sketch.solve
fn curve_required(schema: &mut Schema) {
require(schema, "curve");
}
/// The curves joined end to end to a curve, as a double-click selects them. The design is not
/// changed.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = curve_required)]
pub struct ChainParams {
// Optional in the type and required in the schema (`curve_required`).
/// The curve, by id.
#[serde(default, deserialize_with = "curve_id", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub curve: Option<String>,
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// What `sketch.chain` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ChainResult {
/// The chain's curves, by id, the given one included.
pub curves: Vec<String>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// Report a sketch's solver status and degrees of freedom, as last evaluated. The design is not
/// changed.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SolveParams {
/// The sketch. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
}
/// Whether the solver satisfied the constraints: `solved` or `failed`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SolveOutcome {
// `solved`: all constraints are satisfied; `failed`: they conflict (or the solver could not
// satisfy them) and the geometry stays as it was before solving.
Solved,
Failed,
}
impl From<solidcraft_sketch::SolveStatus> for SolveOutcome {
fn from(status: solidcraft_sketch::SolveStatus) -> Self {
match status {
solidcraft_sketch::SolveStatus::Solved => SolveOutcome::Solved,
solidcraft_sketch::SolveStatus::Failed => SolveOutcome::Failed,
}
}
}
/// What `sketch.solve` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SolveResult {
/// The solver's status.
pub status: SolveOutcome,
/// The degrees of freedom left (0: fully constrained).
pub dof: usize,
/// The constraints still violated after a failed solve, by id.
pub failing: Vec<String>,
/// How many profiles the sketch has.
pub profiles: usize,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "sketch_session_tests.rs"]
mod tests;
@@ -0,0 +1,593 @@
use serde_json::{Value, json};
use super::{
ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult, DimensionTextParams,
DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, MovePointParams, MovePointResult, OptionsParams, OptionsReply,
OptionsResult, SketchOptions, SolveParams, SolveResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
/// A session with a sketch being edited that holds a square (10 mm either side of the origin) and
/// the ids of its four sides.
fn editing(level: ParamCheck) -> (Session, Vec<String>) {
let mut s = Session { param_check: level, ..Session::default() };
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"));
let r = s.execute("sketch.rectangle.center", &json!({"center": [0, 0], "corner": [10, 10]})).unwrap_or_else(|e| panic!("{e}"));
let sides = r["created"]["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
(s, sides)
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
fn error(level: ParamCheck, id: &str, p: &Value) -> Option<String> {
let (mut s, _) = editing(level);
s.execute(id, p).err().map(|e| e.to_string())
}
/// A call the handler already refuses gets the same message in strict, with a sketch being edited.
/// (`assert_strict_keeps_todays_errors` runs after the sketch is finished, so a refusal that comes
/// from the design's side would read "no sketch is being edited" there.)
fn assert_strict_keeps_todays_errors(cases: &[(&str, Value)]) {
for (id, p) in cases {
let off = error(ParamCheck::Off, id, p);
assert!(off.is_some(), "{id} {p} should fail today");
assert_eq!(error(ParamCheck::Strict, id, p), off, "{id} {p}");
}
}
fn messages(id: &str, p: Value) -> Vec<String> {
issues(id, &p).into_iter().map(|i| i.message).collect()
}
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<CreateParams>(),
&["activate_part", "face", "flip", "frame", "name", "offset", "origin", "plane", "project_edges", "x_dir", "y_dir"],
);
assert_keys(input_schema_for::<ConstructionParams>(), &["curves", "sketch", "value"]);
assert_keys(input_schema_for::<DeleteParams>(), &["entities", "sketch"]);
assert_keys(input_schema_for::<MovePointParams>(), &["point", "sketch", "to"]);
assert_keys(input_schema_for::<DragParams>(), &["entity", "from", "live", "sketch", "to", "with"]);
assert_keys(input_schema_for::<DragEndParams>(), &["constraint", "sketch"]);
assert_keys(input_schema_for::<DimensionTextParams>(), &["at", "dimension", "reset", "sketch"]);
assert_keys(
input_schema_for::<OptionsParams>(),
&[
"grid",
"show_constraints",
"show_construction",
"show_dimensions",
"show_points",
"show_profile",
"show_projected",
"sketch",
"sketch_3d",
"slice",
"snap",
],
);
assert_keys(input_schema_for::<ChainParams>(), &["curve", "sketch"]);
assert_keys(input_schema_for::<SolveParams>(), &["sketch"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("sketch.create", json!({"face": [0, 0, 0]})),
("sketch.create", json!({"origin": null})),
("sketch.create", json!({"x_dir": [1, 0, 0]})),
("sketch.create", json!({"y_dir": 3})),
("sketch.create", json!({"activate_part": "yes"})),
("sketch.create", json!({"frame": "local", "plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
("sketch.create", json!({"frame": 1})),
("sketch.create", json!({"frame": "world"})),
("sketch.create", json!({"frame": "component", "plane": "XY"})),
("sketch.create", json!({"plane": true})),
("sketch.create", json!({"plane": null})),
("sketch.create", json!({"plane": [1]})),
("sketch.create", json!({"plane": 1.5})),
("sketch.create", json!({"plane": -1})),
("sketch.create", json!({"plane": "Nope"})),
("sketch.create", json!({"plane": {"x_dir": [1, 0, 0]}})),
("sketch.create", json!({"plane": {"y_dir": [1, 0, 0]}})),
("sketch.create", json!({"plane": {"x_dir": [1, 0, 0], "y_dir": [2, 0, 0]}})),
("sketch.create", json!({"plane": {"origin": "x", "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})),
("sketch.create", json!({"plane": {"face": 5}})),
("sketch.create", json!({"plane": {"face": [0, 0, 0], "fixed": "yes"}})),
("sketch.create", json!({"plane": {"face": [0, 0, 0], "body": ""}})),
("sketch.create", json!({"plane": {"face": [0, 0, 0], "body": "B"}})),
("sketch.create", json!({"plane": {"face": [0, 0, 0], "index": -1}})),
("sketch.create", json!({"plane": {"face": [0, 0, 0], "path": [0]}})),
("sketch.create", json!({"plane": {"face": [0, 0, 0], "path": "x"}})),
("sketch.create", json!({"flip": "z"})),
("sketch.create", json!({"flip": 1})),
("sketch.construction", json!({})),
("sketch.construction", json!({"curves": []})),
("sketch.construction", json!({"curves": null})),
("sketch.construction", json!({"curves": [1]})),
("sketch.construction", json!({"curves": ["l1", 2]})),
("sketch.construction", json!({"curves": 5})),
("sketch.construction", json!({"curves": ["nope"]})),
("sketch.construction", json!({"curves": ["l1"], "sketch": true})),
("sketch.construction", json!({"curves": ["l1"], "sketch": -1})),
("sketch.construction", json!({"curves": ["l1"], "sketch": 99})),
("sketch.delete", json!({})),
("sketch.delete", json!({"entities": []})),
("sketch.delete", json!({"entities": {"a": 1}})),
("sketch.delete", json!({"entities": ["nope"]})),
("sketch.delete", json!({"entities": ["origin"]})),
("sketch.move_point", json!({})),
("sketch.move_point", json!({"to": [1, 2]})),
("sketch.move_point", json!({"to": [1, 2], "point": 5})),
("sketch.move_point", json!({"to": [1, 2], "point": null})),
("sketch.move_point", json!({"to": "x", "point": "origin"})),
("sketch.move_point", json!({"to": null, "point": "origin"})),
("sketch.move_point", json!({"to": [1], "point": "origin"})),
("sketch.move_point", json!({"to": [1, 2], "point": "nope"})),
("sketch.drag", json!({})),
("sketch.drag", json!({"to": [1, 2]})),
("sketch.drag", json!({"to": [1, 2], "entity": 3})),
("sketch.drag", json!({"to": [1, 2], "entity": "nope"})),
("sketch.drag", json!({"to": 3, "entity": "l1"})),
("sketch.drag", json!({"to": [1, 2], "from": "a", "entity": "l1"})),
("sketch.drag", json!({"to": [1, 2], "from": [1], "entity": "l1"})),
("sketch.drag", json!({"to": [1, 2], "entity": "l1", "with": "l2"})),
("sketch.drag", json!({"to": [1, 2], "entity": "l1", "with": [1]})),
("sketch.drag", json!({"to": [1, 2], "entity": "l1", "with": ["nope"]})),
("sketch.drag_end", json!({"constraint": {"command": "solid.box"}})),
("sketch.drag_end", json!({"constraint": {"command": 5}})),
("sketch.drag_end", json!({"constraint": {}})),
("sketch.drag_end", json!({"constraint": 5})),
("sketch.drag_end", json!({"constraint": "sketch.constraint.fix"})),
("sketch.drag_end", json!({"sketch": true})),
("sketch.dimension_text", json!({})),
("sketch.dimension_text", json!({"at": [1, 2]})),
("sketch.dimension_text", json!({"dimension": 5, "at": [1, 2]})),
("sketch.dimension_text", json!({"dimension": null, "reset": true})),
("sketch.dimension_text", json!({"dimension": "d1"})),
("sketch.dimension_text", json!({"dimension": "d1", "at": "x"})),
("sketch.dimension_text", json!({"dimension": "d1", "at": null})),
("sketch.dimension_text", json!({"dimension": "nope", "at": [1, 2]})),
("sketch.options", json!({"sketch": true})),
("sketch.options", json!({"sketch": 99})),
("sketch.options", json!({"show_points": false, "sketch": "nope"})),
("sketch.chain", json!({})),
("sketch.chain", json!({"curve": 5})),
("sketch.chain", json!({"curve": null})),
("sketch.chain", json!({"curve": "nope"})),
("sketch.chain", json!({"curve": "l1", "sketch": [1]})),
("sketch.solve", json!({"sketch": true})),
("sketch.solve", json!({"sketch": 99})),
("sketch.solve", json!({"sketch": "nope"})),
]);
}
/// A flip that is refused on a face gets the same message in strict.
#[test]
fn strict_keeps_the_flip_refusal_on_a_face() {
let error = |level: ParamCheck| {
let mut s = Session { param_check: level, ..Session::default() };
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10}));
let at = json!({"face": [10, 10, 10]});
for flip in [json!(true), json!("x"), json!("Y")] {
let e = s.execute("sketch.create", &json!({"plane": at, "flip": flip}));
assert!(e.is_err(), "{flip}");
}
s.execute("sketch.create", &json!({"plane": at, "flip": true})).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.as_deref().is_some_and(|e| e.contains("a face already faces outward")), "{off:?}");
assert_eq!(error(ParamCheck::Strict), off);
}
/// `conforms` doesn't read patterns, so the text of the two that say what a reader checks.
#[test]
fn the_patterns_are_pinned() {
let schema = input_schema_for::<CreateParams>();
assert_eq!(schema.as_value().pointer("/$defs/Flip/anyOf/1/pattern"), Some(&json!("^[xXyY]$")));
let schema = input_schema_for::<DragEndParams>();
assert_eq!(schema.as_value().pointer("/$defs/DragConstraint/properties/command/pattern"), Some(&json!("^sketch\\.constraint\\.")));
for (flip, ok) in [("x", true), ("Y", true), ("z", false), ("xy", false), ("", false)] {
assert_eq!(messages("sketch.create", json!({"flip": flip})).is_empty(), ok, "{flip}");
}
}
/// Each key is read in the cases its handler reads it, so a call that works today gets no warning:
/// `project_edges` is read only for a picked face, `frame` and `reset` as the handlers do, `at`
/// only without `reset: true`, a constraint only when it is not `null`.
#[test]
fn the_types_read_each_key_as_the_handler_does() {
// sketch.create: `project_edges` is read only on a face; the handler ignores it elsewhere.
assert!(messages("sketch.create", json!({"plane": "XY", "project_edges": "yes"})).is_empty());
assert!(messages("sketch.create", json!({"project_edges": [1]})).is_empty());
assert_eq!(messages("sketch.create", json!({"plane": {"face": [0, 0, 1]}, "project_edges": "yes"})), ["`project_edges` must be true or false"]);
assert!(messages("sketch.create", json!({"plane": {"face": [0, 0, 1]}, "project_edges": false, "activate_part": true})).is_empty());
// `frame` is for an explicit plane only, and `null` reads as left out.
let explicit = json!({"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]});
assert!(messages("sketch.create", json!({"plane": explicit, "frame": "component"})).is_empty());
assert!(messages("sketch.create", json!({"plane": explicit, "frame": null, "flip": "X"})).is_empty());
assert!(messages("sketch.create", json!({"plane": "XZ", "frame": null, "flip": null, "offset": "w / 2", "name": "S"})).is_empty());
assert_eq!(messages("sketch.create", json!({"plane": explicit, "frame": "local"})), ["`frame` must be \"world\" or \"component\""]);
assert!(messages("sketch.create", json!({"plane": "XY", "frame": "world"}))[0].starts_with("`frame` is for an explicit plane"));
// The keys that belong inside `plane` are refused with their own message, not as unknown keys.
assert_eq!(messages("sketch.create", json!({"face": [0, 0, 1]})), [r#"`face` belongs inside `plane`: {"plane": {"face": [x, y, z]}}"#]);
assert_eq!(messages("sketch.create", json!({"plane": "XY", "x_dir": null})).len(), 1);
// A face is not flipped, but `flip: false` is fine.
assert!(messages("sketch.create", json!({"plane": {"face": [0, 0, 1]}, "flip": false})).is_empty());
assert_eq!(
messages("sketch.create", json!({"plane": {"face": [0, 0, 1]}, "flip": true})),
["`flip` is for origin, construction and explicit planes; a face already faces outward"]
);
// sketch.dimension_text: `at` is read only without `reset: true`.
assert!(messages("sketch.dimension_text", json!({"dimension": "d1", "reset": true, "at": "anywhere"})).is_empty());
assert!(messages("sketch.dimension_text", json!({"dimension": "d1", "reset": true})).is_empty());
assert_eq!(messages("sketch.dimension_text", json!({"dimension": "d1", "reset": false, "at": 1})), ["`at` must be a point [x, y]"]);
assert_eq!(messages("sketch.dimension_text", json!({"dimension": "d1", "reset": "yes"})), ["`reset` must be true or false"]);
assert!(messages("sketch.dimension_text", json!({"dimension": "d1", "at": {"x": 1, "y": 2}})).is_empty());
// sketch.drag: `from` may be `null`; `with` too.
assert!(messages("sketch.drag", json!({"entity": "l1", "to": [1, 2], "from": null, "with": null, "live": null})).is_empty());
assert!(messages("sketch.drag", json!({"entity": "l1", "to": {"x": 1, "y": 2}, "from": [0, 0], "with": ["l2", "p3"], "live": false})).is_empty());
assert_eq!(messages("sketch.drag", json!({"entity": "l1", "to": [1, 2], "live": 1})), ["`live` must be true or false"]);
// sketch.drag_end: a constraint only when it is not `null`; its params are the command's own.
assert!(messages("sketch.drag_end", json!({"constraint": null, "sketch": null})).is_empty());
assert!(messages("sketch.drag_end", json!({"constraint": {"command": "sketch.constraint.fix", "params": {"point": "origin"}}})).is_empty());
assert!(messages("sketch.drag_end", json!({"constraint": {"command": "sketch.constraint.fix"}})).is_empty());
assert_eq!(
messages("sketch.drag_end", json!({"constraint": {"command": "sketch.line"}})),
["`constraint.command` must be a sketch.constraint.* command"]
);
// Lists of ids: one text or a list of text, as `string_list` reads them.
assert!(messages("sketch.construction", json!({"curves": "l1", "value": null})).is_empty());
assert!(messages("sketch.delete", json!({"entities": ["l1", "p2", "d1"]})).is_empty());
assert_eq!(messages("sketch.construction", json!({"curves": ["l1", 2]})), ["`curves` must list names; the entry 2 is 2"]);
assert_eq!(messages("sketch.construction", json!({"curves": ["l1"], "value": "yes"})), ["`value` must be true or false"]);
assert_eq!(messages("sketch.delete", json!({"entities": true})), ["`entities` must be a list of names; it is true"]);
// sketch.options: the options are flags; anything else is named.
assert!(messages("sketch.options", json!({"show_points": false, "grid": true, "slice": null, "sketch": 3})).is_empty());
assert_eq!(messages("sketch.options", json!({"snap": "on"})), ["`snap` must be true or false"]);
assert_eq!(messages("sketch.options", json!({"show_profil": true})), ["unknown key `show_profil`; did you mean `show_profile`?"]);
}
/// What the types accept, the handlers accept in Off: each call below runs there and has no issue.
#[test]
fn what_the_types_accept_the_handlers_run() {
let (mut s, sides) = editing(ParamCheck::Off);
let bottom = sides[0].clone();
for (id, p) in [
("sketch.solve", json!({"sketch": null})),
("sketch.chain", json!({"curve": bottom, "sketch": null})),
("sketch.options", json!({"show_points": null, "grid": null})),
("sketch.construction", json!({"curves": bottom, "value": null})),
("sketch.move_point", json!({"point": "origin", "to": {"x": 0, "y": 0}})),
("sketch.drag", json!({"entity": bottom, "to": [0, -10], "from": null, "with": null, "live": null})),
("sketch.drag_end", json!({"constraint": null})),
("sketch.create", json!({"plane": "XY", "frame": null, "flip": false, "project_edges": "ignored"})),
] {
assert!(messages(id, p.clone()).is_empty(), "{id} {p}");
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
}
}
/// The contracts say what the handlers need: the keys a handler refuses without are required in
/// the schemas, though the types leave them to the handlers' messages.
#[test]
fn the_schemas_take_what_the_handlers_read() {
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
let schema = schema.as_value();
for (p, takes) in cases {
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
}
};
takes(
input_schema_for::<CreateParams>(),
&[
(json!({}), true),
(json!({"plane": "XZ", "offset": 5, "flip": "x", "name": "S"}), true),
(json!({"plane": 3, "flip": true}), true),
(
json!({"plane": {"face": [1, 2, 3], "body": "B", "index": 2, "path": [1], "fixed": true}, "project_edges": false, "activate_part": true}),
true,
),
(json!({"plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "frame": "component"}), true),
(json!({"plane": "XY", "frame": "nowhere"}), false),
(json!({"plane": 1.5}), false),
(json!({"plane": null}), false),
(json!({"face": [1, 2, 3]}), false),
(json!({"x_dir": null}), false),
],
);
takes(
input_schema_for::<ConstructionParams>(),
&[
(json!({"curves": ["l1", "l2"]}), true),
(json!({"curves": "l1", "value": false, "sketch": "S"}), true),
(json!({}), false),
(json!({"curves": []}), false),
(json!({"curves": [1]}), false),
],
);
takes(
input_schema_for::<DeleteParams>(),
&[
(json!({"entities": ["l1", "d1"]}), true),
(json!({"entities": "p3", "sketch": 4}), true),
(json!({}), false),
(json!({"entities": []}), false),
],
);
takes(
input_schema_for::<MovePointParams>(),
&[
(json!({"point": "l1.end", "to": [1, 2]}), true),
(json!({"point": "p1", "to": {"x": 1, "y": 2}}), true),
(json!({"to": [1, 2]}), false),
(json!({"point": "p1"}), false),
(json!({"point": 3, "to": [1, 2]}), false),
],
);
takes(
input_schema_for::<DragParams>(),
&[
(json!({"entity": "l1", "to": [1, 2]}), true),
(json!({"entity": "c1", "from": [0, 0], "to": [1, 2], "with": ["p1"], "live": true, "sketch": "S"}), true),
(json!({"entity": "c1", "from": null, "to": [1, 2], "with": null}), true),
(json!({"entity": "l1"}), false),
(json!({"to": [1, 2]}), false),
(json!({"entity": "l1", "to": [1, 2], "with": "p1"}), false),
],
);
takes(
input_schema_for::<DragEndParams>(),
&[
(json!({}), true),
(json!({"constraint": {"command": "sketch.constraint.fix", "params": {"point": "p1"}}}), true),
(json!({"constraint": {"params": {}}}), false),
(json!({"constraint": 5}), false),
],
);
takes(
input_schema_for::<DimensionTextParams>(),
&[
(json!({"dimension": "d1", "at": [1, 2]}), true),
(json!({"dimension": "d1", "reset": true}), true),
(json!({"dimension": "d1", "reset": true, "at": [1, 2]}), true),
(json!({"dimension": "d1", "reset": false}), false),
(json!({"dimension": "d1"}), false),
(json!({"at": [1, 2]}), false),
],
);
takes(
input_schema_for::<OptionsParams>(),
&[(json!({}), true), (json!({"show_points": false, "sketch_3d": true, "sketch": "S"}), true), (json!({"grid": "yes"}), false)],
);
takes(
input_schema_for::<ChainParams>(),
&[(json!({"curve": "l1"}), true), (json!({"curve": "l1", "sketch": 2}), true), (json!({}), false), (json!({"curve": 1}), false)],
);
takes(input_schema_for::<SolveParams>(), &[(json!({}), true), (json!({"sketch": "S"}), true), (json!({"sketch": true}), false)]);
}
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
let schema = schema.as_value();
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
assert!(reply.get("param_warnings").is_none(), "{id}: {reply}");
}
/// Every form of Create Sketch, in Warn: a reply that matches the output schema, no warning.
#[test]
fn create_replies_match_the_output_schema() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let made = |s: &mut Session, p: Value| {
let r = run(s, "sketch.create", p);
matches(output_schema_for::<CreateResult>(), "sketch.create", &r);
run(s, "sketch.finish", json!({}));
r
};
let first = made(&mut s, json!({"plane": "XY"}));
assert_eq!(first["attachment"], "fixed", "{first}");
assert!(first["projected"].is_null() && first.get("frame").is_none(), "{first}");
made(&mut s, json!({}));
made(&mut s, json!({"plane": "xz", "offset": 5, "name": "Wall", "flip": "x"}));
made(&mut s, json!({"plane": "YZ", "offset": "10 / 2", "flip": true, "activate_part": false}));
made(&mut s, json!({"plane": "XY", "flip": false, "frame": null, "project_edges": 7}));
let explicit = json!({"origin": [0, 0, 5], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]});
let world = made(&mut s, json!({"plane": explicit}));
assert_eq!(world["frame"], "world", "{world}");
let own = made(&mut s, json!({"plane": explicit, "frame": "component", "flip": "Y"}));
assert_eq!(own["frame"], "component", "{own}");
// A construction plane by name and by id.
let plane = run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 12, "name": "Mid"}));
made(&mut s, json!({"plane": "Mid"}));
made(&mut s, json!({"plane": plane["feature"]}));
// A picked face, with its edges projected or not, and in another component.
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Block"}));
let top = made(&mut s, json!({"plane": {"face": [10, 10, 10]}}));
assert_eq!(top["attachment"], "exact_face", "{top}");
assert!(top["projected"].is_string(), "{top}");
let bare = made(&mut s, json!({"plane": {"face": [10, 10, 10], "fixed": true}, "project_edges": false, "flip": false}));
assert!(bare["projected"].is_null(), "{bare}");
// The exact pick: the face's index is the one of the six that holds the point.
let exact = (0..6).find_map(|index| {
let pick = json!({"plane": {"face": [10, 10, 10], "body": "Block", "index": index}, "activate_part": true, "name": "On face"});
s.execute("sketch.create", &pick).ok().map(|r| (pick, r))
});
let (_, exact) = exact.unwrap_or_else(|| panic!("no face of the box holds the point"));
matches(output_schema_for::<CreateResult>(), "sketch.create", &exact);
assert_eq!(exact["attachment"], "exact_face", "{exact}");
}
/// The reply's `note` (a world plane into a moved component) and `activated` (a face of another
/// component's body), in Warn: both match the output schema and carry no warning.
#[test]
fn create_note_and_activated_match_the_output_schema() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let part = run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
run(&mut s, "occurrence.move", json!({"occurrence": part["occurrence"], "translate": [10, 0, 0]}));
let r = run(&mut s, "sketch.create", json!({"plane": {"x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
assert!(r["note"].as_str().is_some_and(|n| n.contains("world coordinates")) && r["frame"] == "world", "{r}");
matches(output_schema_for::<CreateResult>(), "sketch.create", &r);
run(&mut s, "sketch.finish", json!({}));
// Another component with a body, then the root active: a face pick with `activate_part`.
run(&mut s, "component.create", json!({"name": "Other", "grounded": true}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "body_name": "Block"}));
run(&mut s, "component.activate", json!({"component": 0}));
let r = (0..6)
.find_map(|index| {
let pick = json!({"plane": {"face": [10, 10, 10], "body": "Block", "index": index}, "activate_part": true});
s.execute("sketch.create", &pick).ok()
})
.unwrap_or_else(|| panic!("no face of the box holds the point"));
assert!(r["activated"]["component"].is_u64() && r["activated"]["path"].is_array(), "{r}");
matches(output_schema_for::<CreateResult>(), "sketch.create", &r);
}
/// The editing commands, in Warn, on a sketch being edited: each reply matches its output schema
/// and carries no warning.
#[test]
fn editing_replies_match_the_output_schema() {
let (mut s, sides) = editing(ParamCheck::Warn);
let (bottom, right) = (sides[0].clone(), sides[1].clone());
let sketch = s.active_sketch.unwrap_or_default();
let r = run(&mut s, "sketch.solve", json!({}));
assert!(r["status"] == "solved" && r["failing"] == json!([]), "{r}");
matches(output_schema_for::<SolveResult>(), "sketch.solve", &r);
matches(output_schema_for::<SolveResult>(), "sketch.solve", &run(&mut s, "sketch.solve", json!({"sketch": sketch})));
let r = run(&mut s, "sketch.chain", json!({"curve": bottom}));
assert!(r["curves"].as_array().is_some_and(|c| c.iter().any(|id| id == bottom.as_str())), "{r}");
matches(output_schema_for::<ChainResult>(), "sketch.chain", &r);
matches(output_schema_for::<ChainResult>(), "sketch.chain", &run(&mut s, "sketch.chain", json!({"curve": right, "sketch": sketch})));
let r = run(&mut s, "sketch.options", json!({}));
assert!(r["options"]["show_points"] == true && r.get("sketch").is_none(), "{r}");
matches(output_schema_for::<OptionsResult>(), "sketch.options", &r);
let r = run(&mut s, "sketch.options", json!({"show_points": false, "grid": false, "slice": true, "sketch": sketch}));
assert!(r["options"]["show_points"] == false && r["options"]["slice"] == true && r["sketch"].is_object(), "{r}");
matches(output_schema_for::<OptionsResult>(), "sketch.options", &r);
let r = run(&mut s, "sketch.options", json!({"show_points": null, "snap": true}));
matches(output_schema_for::<OptionsResult>(), "sketch.options", &r);
let r = run(&mut s, "sketch.construction", json!({"curves": [bottom]}));
assert!(r["construction"] == true && r["changed"] == 1, "{r}");
matches(output_schema_for::<ConstructionResult>(), "sketch.construction", &r);
let r = run(&mut s, "sketch.construction", json!({"curves": bottom, "value": false, "sketch": sketch}));
assert!(r["construction"] == false && r["changed"] == 1, "{r}");
matches(output_schema_for::<ConstructionResult>(), "sketch.construction", &r);
let r = run(&mut s, "sketch.move_point", json!({"point": format!("{bottom}.start"), "to": [-12, -10]}));
assert!(r["sketch"].is_object(), "{r}");
matches(output_schema_for::<MovePointResult>(), "sketch.move_point", &r);
matches(
output_schema_for::<MovePointResult>(),
"sketch.move_point",
&run(&mut s, "sketch.move_point", json!({"point": "origin", "to": {"x": 1, "y": 1}})),
);
let d = run(&mut s, "sketch.dimension", json!({"entities": [right]}));
let param = d["param"].as_str().unwrap_or_default().to_string();
let r = run(&mut s, "sketch.dimension_text", json!({"dimension": param, "at": [30, 12]}));
assert!(r["dimension"].is_string(), "{r}");
let dimension = r["dimension"].clone();
matches(output_schema_for::<DimensionTextResult>(), "sketch.dimension_text", &r);
let r = run(&mut s, "sketch.dimension_text", json!({"dimension": dimension, "reset": true, "at": "ignored"}));
matches(output_schema_for::<DimensionTextResult>(), "sketch.dimension_text", &r);
let r = run(&mut s, "sketch.dimension_text", json!({"dimension": param, "reset": false, "at": [1, 2], "sketch": sketch}));
matches(output_schema_for::<DimensionTextResult>(), "sketch.dimension_text", &r);
let r = run(&mut s, "sketch.drag", json!({"entity": right, "from": [10, 0], "to": [14, 0]}));
assert!(r["reached"].is_number() && r["at"].is_array() && r["retry"] == false, "{r}");
matches(output_schema_for::<DragResult>(), "sketch.drag", &r);
let r = run(&mut s, "sketch.drag", json!({"entity": bottom, "to": [0, -10], "with": [right], "live": false, "sketch": sketch}));
matches(output_schema_for::<DragResult>(), "sketch.drag", &r);
let r = run(&mut s, "sketch.drag_end", json!({}));
assert!(r["constraint"].is_null(), "{r}");
matches(output_schema_for::<DragEndResult>(), "sketch.drag_end", &r);
let fix = json!({"command": "sketch.constraint.fix", "params": {"entity": format!("{bottom}.start")}});
let r = run(&mut s, "sketch.drag_end", json!({"constraint": fix, "sketch": sketch}));
assert!(r["constraint"]["result"].is_object() && r["sketch"] == r["constraint"]["sketch"], "{r}");
// The nested command is not warned about its own call by the outer one: its reply carries what
// its own contract found, here nothing.
matches(output_schema_for::<DragEndResult>(), "sketch.drag_end", &r);
let r = run(&mut s, "sketch.drag_end", json!({"constraint": null}));
matches(output_schema_for::<DragEndResult>(), "sketch.drag_end", &r);
// A constraint that cannot be added is not kept; the drag still ends.
let bad = json!({"command": "sketch.constraint.coincident", "params": {"a": "origin", "b": "origin"}});
let r = run(&mut s, "sketch.drag_end", json!({"constraint": bad}));
assert!(r["constraint"]["error"].is_string(), "{r}");
matches(output_schema_for::<DragEndResult>(), "sketch.drag_end", &r);
let r = run(&mut s, "sketch.delete", json!({"entities": [dimension, bottom]}));
assert_eq!(r["deleted"], 2, "{r}");
matches(output_schema_for::<DeleteResult>(), "sketch.delete", &r);
let r = run(&mut s, "sketch.delete", json!({"entities": right, "sketch": sketch}));
matches(output_schema_for::<DeleteResult>(), "sketch.delete", &r);
}
/// A misspelt key still runs in Warn and says so, with a reply that matches the output schema; in
/// Strict the call is refused with the suggestion.
#[test]
fn a_misspelt_key_is_reported() {
let (mut s, sides) = editing(ParamCheck::Warn);
let bottom = sides[0].clone();
let cases = [
("sketch.solve", json!({"skech": 1}), "skech", "sketch"),
("sketch.chain", json!({"curve": bottom, "sketh": 1}), "sketh", "sketch"),
("sketch.construction", json!({"curves": [bottom], "valu": true}), "valu", "value"),
("sketch.move_point", json!({"point": "origin", "to": [1, 1], "too": [2, 2]}), "too", "to"),
("sketch.drag", json!({"entity": bottom, "to": [0, -9], "frm": [0, -10]}), "frm", "from"),
("sketch.drag_end", json!({"constrant": null}), "constrant", "constraint"),
("sketch.delete", json!({"entities": [bottom], "sketch ": 1}), "sketch ", "sketch"),
("sketch.create", json!({"plane": "XY", "ofset": 5}), "ofset", "offset"),
];
for (id, p, wrong, right) in cases {
let issues = messages(id, p.clone());
assert_eq!(issues, [format!("unknown key `{wrong}`; did you mean `{right}`?")], "{id}");
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert_eq!(r["param_warnings"], json!(issues), "{id}: {r}");
}
// sketch.options refuses any key that is not an option itself, so it never replies with
// warnings; the shape it would have (flattened untagged enum plus `param_warnings`) still
// matches its schema.
let opts = serde_json::to_value(OptionsResult {
options: OptionsReply::Changed {
options: SketchOptions {
show_profile: true,
show_points: true,
show_dimensions: true,
show_constraints: true,
show_projected: true,
show_construction: true,
grid: true,
snap: true,
slice: false,
sketch_3d: false,
},
sketch: None,
},
param_warnings: vec!["w".into()],
})
.unwrap_or_default();
let schema = output_schema_for::<OptionsResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &opts) && opts["sketch"].is_null() && opts["param_warnings"][0] == "w", "{opts}");
// The warned reply still matches its schema.
let r = run(&mut s, "sketch.solve", json!({"skech": 1}));
let schema = output_schema_for::<SolveResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
let (mut strict, _) = editing(ParamCheck::Strict);
let e = strict.execute("sketch.solve", &json!({"skech": 1})).err().map(|e| e.to_string());
assert_eq!(e.as_deref(), Some("invalid parameters for `sketch.solve`: unknown key `skech`; did you mean `sketch`?"));
let e = strict.execute("sketch.options", &json!({"show_profil": true})).err().map(|e| e.to_string());
assert!(e.is_some_and(|e| e.contains("unknown key `show_profil`")));
}
+328 -1
View File
@@ -1,5 +1,5 @@
//! Contracts and result types for the drawing commands in `cmd/sketch.rs`: a line, a point, the
//! circles and the arcs (#12953).
//! circles and the arcs (#12953), the rectangles and the polygons (#13196).
use std::borrow::Cow;
@@ -585,6 +585,333 @@ pub struct PointResult {
pub param_warnings: Vec<String>,
}
/// A rectangle corner as `sketch::corner_arg` reads it: `[x, y]` or `{x, y}` of numbers, or
/// `[x, y]` with a coordinate typed as an expression over the design's parameters. Not a point's
/// id or a snap. Kept as given, for the handler, which evaluates the expressions.
#[derive(Clone, Debug, PartialEq)]
pub struct CornerPoint(pub Value);
impl<'de> Deserialize<'de> for CornerPoint {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
if params::vec2(&v).is_some() {
return Ok(CornerPoint(v));
}
// `corner_arg` reads a pair with a coordinate typed as text as `sketch::expr_point` does,
// which is `point_arg_form`'s expression branch; anything else as `params::req_vec2`.
let typed = v.as_array().is_some_and(|a| a.len() == 2 && a.iter().any(Value::is_string));
if typed {
return crate::cmd::point_arg_form(&v, false).map(|()| CornerPoint(v)).map_err(|why| D::Error::custom(whole(why)));
}
Err(D::Error::custom("must be a point [x, y]"))
}
}
impl JsonSchema for CornerPoint {
fn schema_name() -> Cow<'static, str> {
"CornerPoint".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point2>();
let coordinate = generator.subschema_for::<LengthExpr>();
json_schema!({
"description": "A corner: [x, y] or {x, y} in mm, or [x, y] where a coordinate is an expression over the design's parameters, such as [\"w / 2\", 5]: the corner then follows the parameters. Not a point's id or a snap.",
"anyOf": [
point,
{"type": "array", "items": coordinate, "minItems": 2, "maxItems": 2}
]
})
}
}
/// A number as `params::num` reads it (a polygon's `radius`, `diameter`, `sides` and `angle`): a
/// finite number, never an expression.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlainNumber(pub f64);
impl<'de> Deserialize<'de> for PlainNumber {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?.as_f64().and_then(params::finite).map(PlainNumber).ok_or_else(|| D::Error::custom("must be a number"))
}
}
impl JsonSchema for PlainNumber {
fn schema_name() -> Cow<'static, str> {
"PlainNumber".into()
}
fn inline_schema() -> bool {
true
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "number"})
}
}
/// Draw an axis-aligned rectangle from two opposite corners.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RectTwoPointParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// One corner. A coordinate typed as an expression holds the corner where the parameters put it.
pub p0: CornerPoint,
/// The opposite corner. It must differ from `p0` in both x and y.
pub p1: CornerPoint,
/// Draw construction geometry, which is not part of a profile. Default: false.
#[serde(default)]
pub construction: Option<bool>,
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
/// it. Default: true.
#[serde(default)]
pub connect: Option<bool>,
}
/// Draw a rectangle from one edge and a point on the opposite edge, at any angle.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RectThreePointParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// One end of the first edge, as [x, y] or {x, y} in mm. An expression, a point's id or a
/// snap is not taken.
pub p0: PlainPoint,
/// The other end of the first edge. Not the same point as `p0`.
pub p1: PlainPoint,
/// A point on the opposite edge: its distance from the first edge is the rectangle's width.
/// Not on the first edge's line.
pub p2: PlainPoint,
/// Draw construction geometry, which is not part of a profile. Default: false.
#[serde(default)]
pub construction: Option<bool>,
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
/// it. Default: true.
#[serde(default)]
pub connect: Option<bool>,
}
/// Draw an axis-aligned rectangle from its centre and one corner, with construction diagonals
/// that hold the centre.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RectCenterParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// The centre. A coordinate typed as an expression holds the centre where the parameters put it.
pub center: CornerPoint,
/// One corner, in any quadrant round the centre. It must differ from `center` in both x and y.
pub corner: CornerPoint,
/// Draw construction geometry, which is not part of a profile. Default: false.
#[serde(default)]
pub construction: Option<bool>,
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
/// it. Default: true.
#[serde(default)]
pub connect: Option<bool>,
}
/// Draw an axis-aligned rectangle from two opposite corners, its four corners rounded alike, as
/// one undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct RectRoundedParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// One corner of the rectangle before rounding, as `sketch.rectangle.two_point` takes it.
pub p0: CornerPoint,
/// The opposite corner. It must differ from `p0` in both x and y.
pub p1: CornerPoint,
/// The corner radius, in mm: a number or an expression over the design's parameters. Positive
/// and less than half the shorter side. One radius dimension holds it; the other arcs are
/// equal to it.
pub radius: LengthExpr,
/// Draw construction geometry, which is not part of a profile. Default: false.
#[serde(default)]
pub construction: Option<bool>,
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
/// it. Default: true.
#[serde(default)]
pub connect: Option<bool>,
}
/// Draw a regular polygon whose corners lie on a circle round its centre.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PolygonInscribedParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// The centre, as [x, y] or {x, y} in mm. An expression, a point's id or a snap is not taken.
pub center: PlainPoint,
/// The distance from the centre to each corner, in mm. Positive. Wins over `diameter`. Give
/// one of the two.
#[serde(default)]
pub radius: Option<PlainNumber>,
/// Twice the distance from the centre to each corner, in mm. Used when `radius` is not.
#[serde(default)]
pub diameter: Option<PlainNumber>,
/// The number of sides: a whole number from 3 to 200. Default: 6.
#[serde(default)]
pub sides: Option<PlainNumber>,
/// The direction of the first corner from the centre, in degrees counter-clockwise from the
/// sketch's x axis. Default: 90 (straight up).
#[serde(default)]
pub angle: Option<PlainNumber>,
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
/// it. Default: true.
#[serde(default)]
pub connect: Option<bool>,
}
/// Draw a regular polygon whose sides touch a circle round its centre.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PolygonCircumscribedParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// The centre, as [x, y] or {x, y} in mm. An expression, a point's id or a snap is not taken.
pub center: PlainPoint,
/// The distance from the centre to the middle of each side, in mm. Positive. Wins over
/// `diameter`. Give one of the two.
#[serde(default)]
pub radius: Option<PlainNumber>,
/// Twice the distance from the centre to the middle of each side (the width across flats), in
/// mm. Used when `radius` is not.
#[serde(default)]
pub diameter: Option<PlainNumber>,
/// The number of sides: a whole number from 3 to 200. Default: 6.
#[serde(default)]
pub sides: Option<PlainNumber>,
/// The direction of the middle of the first side from the centre, in degrees counter-clockwise
/// from the sketch's x axis. Default: 90 (straight up).
#[serde(default)]
pub angle: Option<PlainNumber>,
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
/// it. Default: true.
#[serde(default)]
pub connect: Option<bool>,
}
/// Draw a regular polygon from one of its sides: the polygon lies to the left of the side from
/// `p0` to `p1`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct PolygonEdgeParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// One end of the side, as [x, y] or {x, y} in mm. An expression, a point's id or a snap is
/// not taken.
pub p0: PlainPoint,
/// The other end of the side. Not the same point as `p0`.
pub p1: PlainPoint,
/// The number of sides: a whole number from 3 to 200. Default: 6.
#[serde(default)]
pub sides: Option<PlainNumber>,
/// Make a new point that lands exactly on an existing point (the origin too) coincident with
/// it. Default: true.
#[serde(default)]
pub connect: Option<bool>,
}
/// An axis-aligned rectangle's sides by place, as line ids.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct RectSides {
/// The bottom side (least y).
pub bottom: Option<String>,
/// The right side (greatest x).
pub right: Option<String>,
/// The top side (greatest y).
pub top: Option<String>,
/// The left side (least x).
pub left: Option<String>,
}
/// An axis-aligned rectangle's corners by place, as point ids.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct RectCorners {
/// The corner with the least x and y.
pub bottom_left: Option<String>,
/// The corner with the greatest x and least y.
pub bottom_right: Option<String>,
/// The corner with the greatest x and y.
pub top_right: Option<String>,
/// The corner with the least x and greatest y.
pub top_left: Option<String>,
}
/// What `sketch.rectangle.two_point` and `sketch.rectangle.center` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RectResult {
/// The ids of the four sides, counter-clockwise from the bottom side.
pub curves: Vec<String>,
/// The ids of the constraints added: horizontal and vertical sides, a coincident for a corner
/// that landed on an existing point, the centre rectangle's midpoint.
pub constraints: Vec<String>,
/// The sketch's solve: `{solved, dof}`.
pub sketch: Value,
/// The sides by place, so a caller can dimension them without inspecting the sketch.
pub sides: RectSides,
/// The corners by place.
pub corners: RectCorners,
/// Everything the call added to the sketch: `{points, curves, constraints, dimensions}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Created>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What `sketch.rectangle.rounded` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct RoundedRectResult {
/// The ids of the four sides, then the four corner arcs.
pub curves: Vec<String>,
/// The ids of the constraints added: the rectangle's, then the fillet's (tangencies and the
/// arcs equal to the first).
pub constraints: Vec<String>,
/// The sketch's solve after the rounding: `{solved, dof}`.
pub sketch: Value,
/// The sides by place. They end at the arcs now; there are no sharp corners to name.
pub sides: RectSides,
/// The corner arcs: bottom-left, bottom-right, top-right, top-left.
pub arcs: Vec<String>,
/// The parameter that holds the corner radius.
pub param: String,
/// Everything the call added to the sketch: `{points, curves, constraints, dimensions}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Created>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What the polygon commands return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct PolygonResult {
/// The ids of the sides, in order round the polygon.
pub curves: Vec<String>,
/// The ids of the constraints added: equal sides, the corners on their construction circle
/// (and, round a circumscribed polygon, its tangent side), a coincident for a corner that
/// landed on an existing point.
pub constraints: Vec<String>,
/// The sketch's solve: `{solved, dof}`.
pub sketch: Value,
/// The id of the construction circle that holds the polygon to its centre: through the
/// corners of an inscribed or edge polygon, touching the sides of a circumscribed one.
pub support_circle: String,
/// Everything the call added to the sketch: `{points, curves, constraints, dimensions}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Created>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "sketch_shapes_tests.rs"]
mod tests;
+116 -1
View File
@@ -2,7 +2,8 @@ use serde_json::{Value, json};
use super::{
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, DrawResult, LineParams, PointParams,
PointResult,
PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectResult,
RectRoundedParams, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
@@ -32,6 +33,14 @@ fn schemas_and_the_key_check_agree() {
input_schema_for::<ArcCenterPointParams>(),
&["center", "clockwise", "connect", "construction", "end", "id", "sketch", "start", "sweep"],
);
assert_keys(input_schema_for::<RectTwoPointParams>(), &["connect", "construction", "p0", "p1", "sketch"]);
assert_keys(input_schema_for::<RectThreePointParams>(), &["connect", "construction", "p0", "p1", "p2", "sketch"]);
assert_keys(input_schema_for::<RectCenterParams>(), &["center", "connect", "construction", "corner", "sketch"]);
assert_keys(input_schema_for::<RectRoundedParams>(), &["connect", "construction", "p0", "p1", "radius", "sketch"]);
// The polygons draw no construction geometry: their handlers never read `construction`.
assert_keys(input_schema_for::<PolygonInscribedParams>(), &["angle", "center", "connect", "diameter", "radius", "sides", "sketch"]);
assert_keys(input_schema_for::<PolygonCircumscribedParams>(), &["angle", "center", "connect", "diameter", "radius", "sides", "sketch"]);
assert_keys(input_schema_for::<PolygonEdgeParams>(), &["connect", "p0", "p1", "sides", "sketch"]);
}
/// Calls the handler refuses fail in strict with the same message. (The shared helper finishes
@@ -81,6 +90,30 @@ fn strict_keeps_todays_errors() {
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "end": true})),
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "end": null})),
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0]})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [0, 5]})),
("sketch.rectangle.two_point", json!({"p0": "l1.end", "p1": [5, 5]})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [5, true]})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": ["", 5]})),
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": ["5", true]})),
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [0, 5]})),
("sketch.rectangle.center", json!({"center": {"ref": "l1.end"}, "corner": [5, 5]})),
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [10, 10], "radius": 6})),
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [10, 10], "radius": 0})),
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [10, 10], "radius": true})),
("sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [1], "radius": 1})),
("sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [0, 0], "p2": [1, 1]})),
("sketch.rectangle.three_point", json!({"p0": [0, 0], "p1": [5, 0], "p2": [9, 0]})),
("sketch.rectangle.three_point", json!({"p0": "l1.end", "p1": [5, 0], "p2": [5, 5]})),
("sketch.polygon.inscribed", json!({"center": [0, 0]})),
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 0})),
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": "5"})),
("sketch.polygon.inscribed", json!({"center": [0, 0], "radius": 5, "sides": 2})),
("sketch.polygon.inscribed", json!({"center": "l1.end", "radius": 5})),
("sketch.polygon.circumscribed", json!({"center": [0, 0], "diameter": "x"})),
("sketch.polygon.circumscribed", json!({"center": [0, 0], "radius": 5, "sides": 201})),
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [0, 0]})),
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 2.5})),
("sketch.polygon.edge", json!({"p0": [0, 0], "p1": ["5", 0]})),
];
for (id, p) in &cases {
let error = |level| session(level).execute(id, p).err().map(|e| e.to_string());
@@ -215,3 +248,85 @@ fn wrong_typed_keys_are_reported() {
fn input_schema_has(id: &str, key: &str) -> bool {
crate::cmd::find_command(id).and_then(|c| c.contract).is_some_and(|c| c.allowed_keys().contains(key))
}
/// The rectangles and polygons (#13196): every form each takes is accepted (run in warn, no
/// warnings), and each reply matches its own output schema.
#[test]
fn rectangles_and_polygons_reply_with_their_types() {
let schemas = [
("rect", output_schema_for::<RectResult>()),
("draw", output_schema_for::<DrawResult>()),
("rounded", output_schema_for::<RoundedRectResult>()),
("polygon", output_schema_for::<PolygonResult>()),
];
let calls = [
("rect", "sketch.rectangle.two_point", json!({"p0": [100, 0], "p1": [110, 10]})),
("rect", "sketch.rectangle.two_point", json!({"p0": {"x": 100, "y": 20}, "p1": ["110 mm", 30], "construction": true, "connect": false})),
("rect", "sketch.rectangle.two_point", json!({"p0": [100, 40], "p1": [110, 50], "sketch": null})),
("draw", "sketch.rectangle.three_point", json!({"p0": [120, 0], "p1": [130, 0], "p2": [125, 5]})),
("draw", "sketch.rectangle.three_point", json!({"p0": {"x": 120, "y": 20}, "p1": [130, 25], "p2": [120, 30], "construction": true})),
("rect", "sketch.rectangle.center", json!({"center": [150, 5], "corner": [155, 10]})),
("rect", "sketch.rectangle.center", json!({"center": ["150 mm", 25], "corner": [145, 20], "connect": false})),
("rounded", "sketch.rectangle.rounded", json!({"p0": [170, 0], "p1": [190, 10], "radius": 2})),
("rounded", "sketch.rectangle.rounded", json!({"p0": [170, 20], "p1": [190, 30], "radius": "1 mm", "construction": true})),
("polygon", "sketch.polygon.inscribed", json!({"center": [210, 5], "radius": 4})),
("polygon", "sketch.polygon.inscribed", json!({"center": {"x": 210, "y": 25}, "diameter": 8, "sides": 5, "angle": 0})),
("polygon", "sketch.polygon.circumscribed", json!({"center": [230, 5], "radius": 4, "sides": 8})),
("polygon", "sketch.polygon.circumscribed", json!({"center": [230, 25], "diameter": 8, "angle": 45, "connect": false})),
("polygon", "sketch.polygon.edge", json!({"p0": [250, 0], "p1": [255, 0]})),
("polygon", "sketch.polygon.edge", json!({"p0": [250, 20], "p1": [255, 20], "sides": 4})),
];
let mut s = session(ParamCheck::Warn);
for (kind, id, p) in &calls {
let schema = schemas.iter().find(|(k, _)| k == kind).map(|(_, s)| s.as_value()).unwrap();
let r = run(&mut s, id, p.clone());
assert!(conforms(schema, schema, &r), "{id} {p}: {r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
assert!(r["created"]["curves"].is_array(), "{r}");
}
// The replies' keys, as the handlers wrote them before the types: a rounded rectangle names
// its arcs and radius parameter, and no corners.
let keys = |r: &Value| r.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default();
let mut off = session(ParamCheck::Off);
let r = run(&mut off, "sketch.rectangle.two_point", json!({"p0": [100, 0], "p1": [110, 10]}));
assert_eq!(keys(&r), ["constraints", "corners", "created", "curves", "sides", "sketch"]);
assert!(r["sides"]["bottom"].is_string() && r["corners"]["top_left"].is_string(), "{r}");
let r = run(&mut off, "sketch.rectangle.rounded", json!({"p0": [170, 0], "p1": [190, 10], "radius": 2}));
assert_eq!(keys(&r), ["arcs", "constraints", "created", "curves", "param", "sides", "sketch"]);
assert_eq!((r["curves"].as_array().map(Vec::len), r["arcs"].as_array().map(Vec::len)), (Some(8), Some(4)), "{r}");
assert!(r["param"].as_str().is_some_and(|name| !name.is_empty()), "{r}");
let r = run(&mut off, "sketch.polygon.inscribed", json!({"center": [210, 5], "radius": 4}));
assert_eq!(keys(&r), ["constraints", "created", "curves", "sketch", "support_circle"]);
assert_eq!(r["curves"].as_array().map(Vec::len), Some(6), "six sides by default: {r}");
// The same calls give the same JSON with the check off and in strict.
let (mut off, mut strict) = (session(ParamCheck::Off), session(ParamCheck::Strict));
for (_, id, p) in &calls {
assert_eq!(run(&mut off, id, p.clone()), run(&mut strict, id, p.clone()), "{id} {p}");
}
}
/// What the rectangles and polygons ignore or read as left out today is reported: a number typed
/// as text, a key a polygon never reads.
#[test]
fn rectangles_and_polygons_report_wrong_typed_keys() {
let message = |id: &str, p: Value| issues(id, &p).first().map(|i| i.message.clone());
let polygon = json!({"center": [0, 0], "radius": 5});
for key in ["sides", "angle"] {
let mut bad = polygon.clone();
bad[key] = json!("6");
assert_eq!(message("sketch.polygon.inscribed", bad), Some(format!("`{key}` must be a number")));
}
let mut bad = polygon.clone();
bad["construction"] = json!(true);
assert_eq!(issues("sketch.polygon.inscribed", &bad).len(), 1, "a polygon draws no construction geometry");
assert_eq!(
message("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [5, 5], "construction": "yes"})).as_deref(),
Some("`construction` must be true or false")
);
assert_eq!(
message("sketch.rectangle.center", json!({"center": [0, 0], "corner": [5, 5], "sketch": true})).as_deref(),
Some("`sketch` must be an id or a name")
);
assert_eq!(message("sketch.rectangle.rounded", json!({"p0": ["w / 2", 0], "p1": [5, 5], "radius": "r"})), None);
assert_eq!(message("sketch.polygon.edge", json!({"p0": [0, 0], "p1": [5, 0], "sides": 4})), None);
}
-51
View File
@@ -2,11 +2,6 @@
# (coverage_tests.rs). This list only shrinks: when you give a command its contract and output
# type, remove its line. Never add one: a new command comes with both schemas. Sorted, one id
# per line.
component.capture_position
component.move_bodies
component.move_sketches
component.paste_new
component.revert_position
doc.open
doc.recover
doc.recovery_discard
@@ -20,14 +15,6 @@ file.save
file.save_as
file.save_mesh
file.slicers
joint.cam
joint.gear
joint.motion_link
joint.origin
joint.rack_pinion
joint.reach
joint.rigid_group
joint.screw
joint.sweep
mesh.convert
motion.export_animation
@@ -36,11 +23,6 @@ motion.plot
motion.trace
motion.trace.edit
motion.trace.keep
occurrence.copy
occurrence.drag
occurrence.ground
occurrence.move
occurrence.properties
render.aging.describe
render.backends
render.cancel
@@ -52,36 +34,11 @@ render.result.regrade
render.scene
render.start
render.status
select.clear
select.set
sheet.export_bend_table
sheet.export_dxf
sheet.flat_pattern
sheet.path_flange
sketch.chain
sketch.construction
sketch.create
sketch.delete
sketch.dimension
sketch.dimension_text
sketch.drag
sketch.drag_end
sketch.draw.dimensioned
sketch.export_dxf
sketch.insert_dxf
sketch.insert_svg
sketch.move_point
sketch.options
sketch.polygon.circumscribed
sketch.polygon.edge
sketch.polygon.inscribed
sketch.rectangle.center
sketch.rectangle.rounded
sketch.rectangle.three_point
sketch.rectangle.two_point
sketch.slot.center_to_center
sketch.slot.overall
sketch.solve
solid.align
solid.chamfer
solid.coil
@@ -119,11 +76,3 @@ surface.thicken
surface.trim
surface.unstitch
surface.untrim
timeline.group
timeline.move_many
timeline.move_to_group
timeline.redefine
timeline.rename_group
timeline.reorder
timeline.reorder_many
timeline.ungroup
+68
View File
@@ -96,6 +96,74 @@ fn edge_picks_at_a_corner_are_refused() {
}
}
/// Edge picks on bodies apart: one pick keeps its body on the feature (the model has two), and
/// picks on both keep none, each found where it lies.
#[test]
fn edge_picks_on_bodies_apart_keep_the_body_they_lie_on() {
let boxes = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Low"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "corner": [0, 0, 50], "operation": "new", "body_name": "High"}));
s
};
let kept = |s: &Session| match &s.doc.features.last().unwrap().kind {
solidcraft_doc::FeatureKind::Chamfer { body, .. } => body.as_ref().map(|b| b.0.clone()),
k => panic!("{k:?}"),
};
let each = |s: &Session| ["Low", "High"].map(|b| s.model.state().body(b).unwrap().measure().unwrap().volume);
let mut s = boxes();
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0]], "distance": 1}));
assert_eq!(kept(&s).as_deref(), Some("Low"));
let [low, high] = each(&s);
assert!((low - 11980.0).abs() < 0.02 && high == 12000.0, "{low} {high}");
let mut s = boxes();
run(&mut s, "solid.chamfer", json!({"edges": [[20, 0, 0], [20, 0, 60]], "distance": 1}));
assert_eq!(kept(&s), None);
let [low, high] = each(&s);
assert!((low - 11980.0).abs() < 0.02 && (high - 11980.0).abs() < 0.02, "{low} {high}");
}
/// The halves of a split box share the edges along the cut: a pick on one is refused naming both
/// halves, and `body` cuts the half named.
#[test]
fn edge_picks_on_the_cut_of_a_split_body_name_both_halves() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Box"}));
run(&mut s, "solid.split_body", json!({"body": "Box", "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}}));
let features = s.doc.features.len();
let err = s.execute("solid.chamfer", &json!({"edges": [[20, 15, 10]], "distance": 1})).unwrap_err().to_string();
assert!(err.contains("[20, 15, 10] lies on edges of 2 bodies, Box and Box (1)") && err.contains("with `body`"), "{err}");
assert_eq!(s.doc.features.len(), features);
run(&mut s, "solid.chamfer", json!({"edges": [[20, 15, 10]], "distance": 1, "body": "Box (1)"}));
let half = |s: &Session, b: &str| s.model.state().body(b).unwrap().measure().unwrap().volume;
assert!((half(&s, "Box (1)") - 5985.0).abs() < 0.02 && (half(&s, "Box") - 6000.0).abs() < 1e-6, "{} {}", half(&s, "Box"), half(&s, "Box (1)"));
}
/// A box touching a cylinder along the cylinder's seam: the seam is not an edge to blend, so the
/// box's edge there is cut.
#[test]
fn an_edge_along_another_bodys_seam_is_picked() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 10, "body_name": "Rod"}));
let rod = s.model.state().body("Rod").unwrap().body.clone();
let mesh = rod.mesh(&solidcraft_kernel::MeshTolerance::chord(0.06)).unwrap();
let seam = mesh.edges.iter().zip(&mesh.seams).find(|(_, seam)| **seam).unwrap().0[0];
// The box's quarter past the seam's line is outside the cylinder: they touch along it.
run(
&mut s,
"solid.box",
json!({"length": 20, "width": 20, "height": 10, "corner": [seam.x, seam.y, 0], "operation": "new", "body_name": "Block"}),
);
run(&mut s, "solid.chamfer", json!({"edges": [[seam.x, seam.y, 5]], "distance": 1}));
let kept = match &s.doc.features.last().unwrap().kind {
solidcraft_doc::FeatureKind::Chamfer { body, .. } => body.as_ref().map(|b| b.0.clone()),
k => panic!("{k:?}"),
};
assert_eq!(kept.as_deref(), Some("Block"));
let v = |b: &str| s.model.state().body(b).unwrap().measure().unwrap().volume;
assert!((v("Block") - 3995.0).abs() < 0.02 && (v("Rod") - PI * 1000.0).abs() < 1.0, "{} {}", v("Block"), v("Rod"));
}
#[test]
fn fillet_as_wide_as_a_face_rounds_it_off() {
// #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces).
+1 -2
View File
@@ -6,9 +6,8 @@ use solidcraft_geom::prelude::*;
use std::sync::Arc;
use solidcore_record::{Plane, Vec3};
use solidcraft_color::AnalysisColors;
use solidcraft_geom::Aabb3;
use solidcraft_render::{Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh};
use solidcraft_render::{AnalysisColors, Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh};
use solidcraft_sketch::Sketch;
use crate::{Result, Sel, Session};
+2 -2
View File
@@ -222,7 +222,7 @@ const MARK: Palette = Palette {
cap: Rgb(55, 56, 57),
hatch: [Rgb(61, 62, 63), Rgb(64, 65, 66)],
isocurve: Rgb(58, 59, 60),
analysis: solidcraft_color::AnalysisColors {
analysis: solidcraft_render::AnalysisColors {
draft: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]],
access: [[0.11, 0.12, 0.13], [0.14, 0.15, 0.16]],
curvature: [[0.17, 0.18, 0.19], [0.21, 0.22, 0.23], [0.24, 0.25, 0.26]],
@@ -253,7 +253,7 @@ fn the_default_palette_is_the_colour_table() {
(colors::SKETCH_PROJECTED, colors::SKETCH_LOST, colors::SKETCH_CONSTRUCTION, colors::SKETCH_CENTERLINE)
);
assert_eq!((p.axes, p.cap, p.isocurve), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP, colors::ISOCURVE));
assert_eq!(p.analysis, solidcraft_color::AnalysisColors::DEFAULT);
assert_eq!(p.analysis, solidcraft_render::AnalysisColors::DEFAULT);
}
#[test]
+1
View File
@@ -40,6 +40,7 @@ mod profile;
pub mod section;
pub mod simplify;
pub mod spline_space;
pub mod srgb;
pub mod stderr;
pub mod tess;
pub mod title_block;
-1
View File
@@ -11,7 +11,6 @@ workspace = true
[dependencies]
solidcore-record = { workspace = true }
robust = "1.2"
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-render = { workspace = true }
wgpu = { workspace = true }
+33 -5
View File
@@ -2223,7 +2223,7 @@ pub struct Renderer {
/// Lines behind the model (depth test >).
line_hidden: wgpu::RenderPipeline,
overlay: wgpu::RenderPipeline,
/// Reference planes and axes: as `trans` and `line`, but not cut by a section.
/// Reference planes, axes and the ground shadow: as `trans` and `line`, but not cut by a section.
guide_trans: wgpu::RenderPipeline,
guide_line: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
@@ -4069,9 +4069,10 @@ impl Renderer {
tris(pass, &res.ghost, &pv.ghost);
pass.set_bind_group(0, scene_bind(m, &res.bind), &[]);
tris(pass, &res.ghost, &m.ghost);
tris(pass, &res.trans, &m.shadow);
// The ground shadow is never cut by a section, like the reference planes (#10708).
tris(pass, &res.guide_trans, &m.shadow);
pass.set_bind_group(0, scene_bind(sk, &res.bind), &[]);
tris(pass, &res.trans, &sk.shadow);
tris(pass, &res.guide_trans, &sk.shadow);
pass.set_bind_group(0, scene_bind(m, &res.bind), &[]);
if phase == Phase::BeforeSeeThrough {
return;
@@ -4527,7 +4528,7 @@ fn uniform_bytes_placing(cb: &Frame, w: f32, h: f32, linear: bool, reversed: boo
/// The shader's analysis colours (`ana_c`: draft positive, between, negative; reachable,
/// blocked; curvature flat, middle, tight; alpha 1) and flags (`ana_f.x`: 1 to stripe the bad
/// faces).
pub fn analysis_colors(c: &solidcraft_color::AnalysisColors) -> [f32; 36] {
pub fn analysis_colors(c: &solidcraft_render::AnalysisColors) -> [f32; 36] {
let mut v = [0.0; 36];
let colors = c.draft.iter().chain(&c.access).chain(&c.curvature).flat_map(|rgb| [rgb[0], rgb[1], rgb[2], 1.0]);
for (slot, x) in v.iter_mut().zip(colors) {
@@ -5070,6 +5071,28 @@ mod glass_tests {
}
}
/// With Section Analysis on, the ground shadow stays whole: the section plane hides cut bodies,
/// not the soft shadow under them, which a cut ended in a hard straight line (#10708).
#[test]
fn ground_shadow_is_not_cut_by_the_section_plane() {
let mut scene = GpuScene::default();
let square = [[-5.0, -5.0, -5.0], [5.0, -5.0, -5.0], [5.0, 5.0, -5.0], [-5.0, -5.0, -5.0], [5.0, 5.0, -5.0], [-5.0, 5.0, -5.0]];
let shadow_color = scene.color([0, 0, 0, 128]);
for p in square {
scene.shadow_tri(p, shadow_color);
}
// Hides everything with x > -1, the middle pixel among it.
let cut = [1.0, 0.0, 0.0, -1.0];
for view_dependent in [false, true] {
let (model, sketch) = if view_dependent { (GpuScene::default(), scene.clone()) } else { (scene.clone(), GpuScene::default()) };
let Some(shadow) = middle_pixel_cut(model, sketch, true, 1, cut) else {
eprintln!("skipping: no GPU adapter");
return;
};
assert!(shadow[..3].iter().all(|c| (125..=129).contains(c)), "view-dependent={view_dependent} shadow under a section: {shadow:?}");
}
}
/// A large import's triangles once went into one buffer past the device's limit (524 MB
/// against 256 MB) and the app crashed (#883): they are split into whole triangles.
#[test]
@@ -5234,6 +5257,11 @@ mod glass_tests {
}
fn middle_pixel_in_slots_with_samples(scene: GpuScene, sketch: GpuScene, oit: bool, samples: u32) -> Option<[u8; 4]> {
middle_pixel_cut(scene, sketch, oit, samples, [0.0; 4])
}
/// [`middle_pixel_in_slots_with_samples`] with a section cut (`Frame::clip`).
fn middle_pixel_cut(scene: GpuScene, sketch: GpuScene, oit: bool, samples: u32, clip: [f32; 4]) -> Option<[u8; 4]> {
const SIZE: u32 = 64;
let (_, device, queue) = crate::open_headless().ok()?;
let depth = crate::depth_format(&device, true);
@@ -5261,7 +5289,7 @@ mod glass_tests {
view_proj: [[0.1, 0.0, 0.0, 0.0], [0.0, 0.1, 0.0, 0.0], [0.0, 0.0, -0.01, 0.0], [0.0, 0.0, 0.5, 1.0]],
back: [0.0, 0.0, 1.0],
size_px: [SIZE as f32; 2],
clip: [0.0; 4],
clip,
cap: [0.0; 4],
images: Vec::new(),
clouds: Vec::new(),
+2 -2
View File
@@ -301,7 +301,7 @@ fn pictures_show_the_surface_analysis() {
s.execute("inspect.curvature_map", &json!({})).unwrap();
let curvature = picture(&s);
let near = |p: [u8; 4], c: [u8; 3]| (0..3).all(|k| (i32::from(p[k]) - i32::from(c[k])).abs() < 60);
let flat = solidcraft_color::AnalysisColors::DEFAULT.curvature[0].map(|v| (v * 255.0).round() as u8);
let flat = solidcraft_engine::render::AnalysisColors::DEFAULT.curvature[0].map(|v| (v * 255.0).round() as u8);
let flats = |img: &[u8]| pixels(img).filter(|p| near(*p, flat)).count();
// The flat colour is dark, so the plain picture's edge lines come near it in places.
assert!(
@@ -317,7 +317,7 @@ fn pictures_show_the_surface_analysis() {
assert!(black > 500 && white > 500, "zebra: {black} black, {white} white pixels");
s.execute("inspect.accessibility", &json!({})).unwrap();
let access = picture(&s);
let [ok, blocked] = colors_of(solidcraft_color::AnalysisColors::DEFAULT.access);
let [ok, blocked] = colors_of(solidcraft_engine::render::AnalysisColors::DEFAULT.access);
let (reachable, under) = (pixels(&access).filter(|p| near(*p, ok)).count(), pixels(&access).filter(|p| near(*p, blocked)).count());
assert!(reachable > 1000 && under > 1000, "accessibility: {reachable} reachable, {under} blocked pixels");
}
-1
View File
@@ -14,7 +14,6 @@ trust-unknown-source = []
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
solidcraft-kernel = { workspace = true }
solidcraft-doc = { workspace = true }
+1 -1
View File
@@ -842,7 +842,7 @@ impl<'d> Reader<'d, '_, '_> {
/// A linear base colour channel as an 8-bit sRGB code; one that isn't a number reads as 0.8.
fn srgb(c: f32) -> u8 {
solidcraft_color::srgb::encode8(if c.is_finite() { f64::from(c) } else { 0.8 })
solidcraft_geom::srgb::encode8(if c.is_finite() { f64::from(c) } else { 0.8 })
}
/// A material's look: base colour, alpha when blended, metallic/roughness when opaque.
-1
View File
@@ -14,7 +14,6 @@ raytrace-cpu = ["dep:robust"]
[dependencies]
solidcore-record = { workspace = true }
solidcraft-color = { workspace = true }
solidcraft-geom = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+31 -1
View File
@@ -1,7 +1,37 @@
//! Surface analysis as a view draws it: the shading mode and the colours it uses.
use solidcore_record::Vec3;
use solidcraft_color::AnalysisColors;
/// Surface analysis colours, straight sRGB 0..1 as the shader takes them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AnalysisColors {
/// Draft Analysis: at least the draft angle, between, at most minus the angle.
pub draft: [[f32; 3]; 3],
/// Accessibility Analysis: reachable, blocked.
pub access: [[f32; 3]; 2],
/// Curvature Map: flat, the middle of the scale, at or below the analysis radius. Dark blue
/// to light yellow in one straight blend, so the lightness rises the whole way and the scale
/// reads with hue lost too (#975).
pub curvature: [[f32; 3]; 3],
/// Stripe the negative draft and blocked faces too, so colour is not the only cue.
pub hatch: bool,
}
impl AnalysisColors {
/// The light theme's colours (no colour vision mode): what headless pictures use.
pub const DEFAULT: AnalysisColors = AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.08, 0.12, 0.45], [0.53, 0.51, 0.425], [0.98, 0.90, 0.40]],
hatch: false,
};
}
impl Default for AnalysisColors {
fn default() -> Self {
AnalysisColors::DEFAULT
}
}
/// A way of shading the model's faces to judge their shape.
#[derive(Clone, Copy, Debug, PartialEq)]
+1 -1
View File
@@ -10,7 +10,7 @@ use super::{CompletePass, MAX_BYTES, invalid, limit};
use crate::job::encoding::PngEncoding;
use crate::job::{CancelToken, CpuHost, ErrorCode, JobError, Phase, Progress, Reservation, Result};
use solidcore_record::Vec3;
use solidcraft_color::srgb::encode8;
use solidcraft_geom::srgb::encode8;
/// Encoded bytes retain the actual existing host lease. No detached byte getter.
pub struct CompletedPng {

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