Compare commits

...

18 Commits

Author SHA1 Message Date
Dan Willis 44525da669 Sketch Project: a face of an imported mesh projects its flat patch (#13464) (#13482)
* Sketch Project: a face of an imported mesh projects its flat patch (#13464)

An imported mesh is one face with no edges, so Project on one of its
faces always failed with "projects to nothing on this sketch plane". A
pick on a mesh body's face now means the flat patch of triangles there,
traced as closed loops (project3d::flat_outline) and fitted like other
traced outlines. The link keeps the pick's point instead of the mesh's
one face name, so the sketch still rebuilds.

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

* Sketch Project: a mesh face's polygon keeps its straight sides on a slanted plane (#13464)

Any triangle's corners and any rectangle's lie on one circle, so on a
sketch plane at a slant the outline could be fitted as a circle seen at
a slant (an ellipse). A mesh outline is now taken for a circle only when
it has more than 12 corners on one circle, checked within the single
precision a mesh file keeps; otherwise its sides stay lines. Corners
where the outline runs straight on (a triangle meeting a side part way
along) are dropped. From CodeRabbit's review on #13482.

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

* Sketch Project: mesh outline tolerances follow single precision and the outline's size (#13464)

A corner part way along a side is dropped when it lies within single
precision of the line through its neighbours (1e-6 of the coordinates'
size), measured as its distance from that line, so a rounded corner at
the loop's start no longer splits a side in two. The circle check for a
mesh outline is never looser than 1e-4 of the outline's own size, so a
small outline far from the origin is not taken for a circle. From
CodeRabbit's second review on #13482.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:27:06 +00:00
Loohi 33c442f031 Sweep: input and output schemas (part of #2829) (#13411)
* claim: part of #2829, sweep schemas

* Sweep and Surface Sweep: input and output schemas (part of #2829)

solid.sweep and surface.sweep get typed contracts and FeatureResult as
their output schema, so warn mode reports and strict refuses a misspelt
or wrong-typed key (orientation, twist, scale, rails, rail_fit, a rail's
own keys). Strict keeps the handlers' messages; replies are unchanged.

The JSON-only readers for orientation and rail_fit are split out of
sweep_options and guide_rails, unchanged, so the contract types call
them. inspect_tests' unconverted example moves to solid.emboss.

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

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 18:59:45 +00:00
Kai Evans 8d47023049 Sketch: a dimension edit the solver gave up on is retried in steps and reaches the new sizes (#12149)
A value edit the one-jump solve fails or turns over is retried in 2, 4, 8,
then 16 equal steps, each a full solve of the whole sketch from the last
step's solution. Continuation follows one solution branch by the iteration
alone, sub-step to sub-step; that branch ends before the new values on the
linkage family, where a solve from the values a step before still reaches
them keeping every turn. The retry runs when the stored sketch is solved for
the old values, as `ValueEdit::starts_solved` decides per component, so a
sketch stored unsolved keeps its one-jump result.

Linkage family, 2,400 random edits: 1,540 refused before, 1,533 after, the 7
newly solved keeping every turn. 941 edits on 18 sketches from real designs:
34 refused before and after.

Tests: solidcraft-sketch 333 passed, 0 failed, 3 ignored.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-08 18:52:41 +00:00
github-actions[bot] 3c619790ce Regenerate generated files and fold pending files [skip ci] 2026-11-08 19:48:58 +00:00
Mek f4d9519bb9 Hole: a counterbore sized to the screw's head, from clearance and head (#12321) (#12322)
* claim: #12321

* Hole: a counterbore sized to the screw's head, from `clearance` and `head` (#12321)

`solid.hole` takes `head`: `ISO 4762` (socket head) or `ISO 7380-1` (button head),
or the family name `parts.insert` uses. With `clearance`, the hole gets a counterbore
the head sits in: the head's diameter dk plus `head_gap` each side, the head's height k
plus `head_gap` deep (default 0.5 mm). `type` defaults to counterbore with `head`; a
`cb_diameter` or `cb_depth` given keeps its value. A `head_gap` that names a parameter
is stored as an expression, so the recess follows it. The lookup, `cap_screw_head`,
lives beside the tables in parts.rs.

Refused with the value named: an unknown standard, a size the standard lacks, `head`
without `clearance`, `head` with another type (no countersink table yet), a gap outside
0 to dk, a `head` that is not text.

Tests: four holes against analytic volumes, a hole matrix case, nine refusal cases,
and the hostile-parameter sweep now tries `head` and `head_gap`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* MCP: a test that a hole's head reaches solid.hole through execute (#12321)

`describe_command solid.hole` lists `head` and `head_gap` and has no typed
contract, so `execute` forwards the parameters to the command unchanged. The
test drills an ISO 4762 and an ISO 7380-1 counterbore through the tool with an
`expect` on the volume, and checks that a wrong head, a negative gap and a head
without a clearance are refused with the value named, leaving the part as it
was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Mek <mf@linescode.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-08 18:52:33 +00:00
Loohi 9890abae36 Sketch: input and output schemas for the slots (part of #2829) (#13300)
* claim: part of #2829, slot schemas

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

* Sketch: input and output schemas for the slots (part of #2829)

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

* Slots: pin the reply to the old shape (part of #2829)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:51:50 +00:00
ancient_astronaut_theorist 28cfed5e07 Copy and Paste New: an edge name beside a body face points at the copy on both sides (#13097) (#13157)
* claim: #13097

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

* Copy and Paste New: an edge name beside a body face maps both faces (#13097)

remap_topology read an edge name whose first face is a face of a body
without history (`Plate:face:3|F2:top`) as one body face: it renamed the
body and left `F2` naming the original feature. With the body face second
(`F2:top|Plate:face:3`) the body kept its old name. It now reads an edge
name with EdgeName::parse first and remaps each face on its own, keeping
the edge's `~k` number. A body whose own name holds a `|` is still one face.

Fixes #13097

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:51:20 +00:00
Loohi 01042ca81e Project: pick a point of another sketch (#13246) (#13281)
* claim: #13246

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

* Project: pick a point of another sketch (#13246)

An input that takes points of other sketches only (Project) was offered
only the active sketch's points. Offer the other sketches' points and
leave out the active sketch's own.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:49:36 +00:00
PeppaWutz 42cd362748 Agents and scripts: Save and Save As describe their exact parameters and replies (#13307) (#13352)
file.save and file.save_as now publish typed input and output schemas, so
agents and scripts can read which parameters they take and what they return,
and the warn and strict parameter checks cover them.

Each parameter is read the way the handler reads it today. Save As keeps its
"`path` is required" message, Save and Save As keep refusing an unknown format
or compression word themselves, and the reply is built from a typed value that
serializes 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 18:49:20 +00:00
MarkusRe bfee9bcc19 Sketch Tangent: an end that already holds is redundant, as for two curves (#13356)
* Sketch Tangent: test that an end that already holds is redundant

Tangent with a curve's end point and a curve the end already touches
tangentially should move nothing, and with both fixed report the tangent
as redundant (or refuse it with `if_redundant: "refuse"`), as Tangent on
two curves does. Fails on main: the call is refused as a conflict.

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

* Sketch Tangent: an end that already holds is redundant, as for two curves

Tangent with a curve's end point skipped what Tangent on two curves does
through `constrain`. Now constraints that already hold move nothing, and
when they remove no degree of freedom they are reported as redundant with
what determines them, or refused with `if_redundant: "refuse"`. Before,
a line's end already tangent on a fixed circle was refused as a conflict.

Follow-up to #12377, from its CodeRabbit review.

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

* Sketch Tangent: test which constraints determine an end that already holds

Free curves whose own constraints decide the tangent at a line's end:
the reply names exactly the Concentric and the Tangent on the equal
circle. The equal radius is not named, because a tangent read at its
touch point says the line is square to the radius there.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:49:00 +00:00
PeppaWutz 1210ac5080 Agents and scripts: Sketch Dimension describes its exact parameters and reply (#13312) (#13355)
sketch.dimension now publishes typed input and output schemas. Each
parameter is read the way the handler reads it today and refused with the
handler's own message, and the reply is built from the same typed value the
output schema describes, so the JSON it returns does not change. A driven
dimension does not read value, scale_sketch or if_redundant, as the handler
does not. Its line leaves 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; review fixes made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 18:48:48 +00:00
Sebastian Höpfl 6e27cfa7ff Tests: reuse one offscreen GPU device per test process (#13395) (#13396)
* claim: #13395

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

* Tests: reuse one offscreen GPU device per test process (#13395)

Every offscreen render or check in the ui-egui tests started its own wgpu instance, adapter and
device, and a fresh device compiled the viewport's pipelines again. `offscreen_device` now hands
out clones of one device per test process. In
`a_hovered_face_lights_whole_over_a_body_drawn_from_its_plan` (28 renders) that takes the test
from 55.1 s to 4.1 s alone: device setup was 9.4 s of it, pipeline compiles 44.3 s.

The tests that destroy their device, or replace its uncaptured-error handler, keep a device of
their own through `offscreen_own_device`: the three device-loss tests in `gpu.rs`,
`a_configure_that_meets_a_lost_device_is_not_taken` and `an_uncaught_gpu_error_is_not_a_panic`.
In wgpu 30 error scopes are per thread, and a new device-lost callback drops the old one without
calling it, so the other tests don't see each other on the shared device.

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

* Tests: retry the shared offscreen device after a failed start (#13395)

`offscreen_device` kept the first result, so one failed device start (a GPU briefly out of
memory) left `None` cached and every later offscreen test in the process skipped. It now keeps
only a device: after a failure the next test tries again, as each test did before the sharing.
Under `SOLIDCRAFT_GPU=cpu` or without an adapter every call still answers `None`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:48:00 +00:00
0rdnungsamt 5c95fb027c Tests: the extrude free-drag test holds the platform's primary key, so it passes on macOS (#13274) (#13296)
* claim: #13274

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

* Tests: hold the platform's primary key for the extrude free drag (#13274)

The free-drag tests held physical Ctrl, which is not the primary key on
macOS, so the arrow still snapped to the face there. Set
Modifiers::command instead, and check for both platforms that Held::new
sees a primary key, so the mistake also shows on Windows and Linux.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:48:00 +00:00
ABDULLAH 1dcb1066e5 Arabic: focused-panel wording in the sash reset and panel toggle commands, shared panel layout help, construction-geometry projection note, Browser panel name in render strings (#13320)
* Arabic: focused-panel wording in the sash reset and panel toggle commands, shared panel layout help

* Arabic: command names and clearer wording in the construction-geometry projection note

* Arabic: the Browser panel is المستعرض in the render browser strings and the shared panel layout help
2026-11-08 18:46:59 +00:00
Janiz-zzz 94cb149856 Finnish interface: translate the 618 messages added since #12036, and five wording fixes (#13460)
* Finnish: translate the 618 messages added since #12036, and five wording fixes

- assets/locales/pending/finnish-catch-up-2026-11-08.json: 618 messages
  (renderer settings, interface, preferences, sketch, commands, dialog titles).
- fi.json, the CodeRabbit notes on #12036: puolisiin -> puoliin; the import
  report hint, double-click help, Fold items and Select Other wording.
- fi.json: cmd.sheet_unfold "Avaa" (open) -> "Levitä" (unfold), matching the
  new UNFOLD dialog title.

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

* i18n(fi): live preview shows the last rendered image, not a saved one

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

---------

Co-authored-by: Janiz-zzz <267742026+Janiz-zzz@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:45:21 +00:00
bherbruck 9e3e441a2d xtask layers: add the solidcore seam rule (#13498)
SolidCraft reaches solidcore only through the crates listed in
xtask/solidcore-public.txt (solidcore-ops, solidcore-tess, solidcore-record).
Any other solidcore crate, matched by package name or by where it comes from
(git or the vendored solidcore/ directory), fails `cargo xtask layers` unless
xtask/solidcore-seam.txt lists it. That list only shrinks.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:06:32 +00:00
Frank 3d78129af6 cargo xtask: check the git worktree it is run in, not the one that last built xtask (#13417)
* claim: cargo xtask uses the checkout it runs in

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

* cargo xtask: check the git worktree it is run in, not the one that last built xtask

root() now prefers the CARGO_MANIFEST_DIR that cargo run sets for the
process, and falls back to the compile-time value only when it is unset.
Worktrees that share one CARGO_TARGET_DIR share one xtask binary, so the
baked-in path named whichever worktree built xtask last.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:35:17 +00:00
bherbruck 3fb1040164 Mesher: flat faces mesh as their boundary alone, so a box exports as 12 triangles, not 124 (#13266)
* Mesher: flat faces are their boundary alone, not a grid capped at a fifth of the body

A planar face is exact for any triangulation of its boundary, so it needs no interior
cells. The planner capped every face, flat ones included, at a fifth of the body's
extent, so a 20 x 10 x 5 box came out as 124 triangles instead of 12.

A flat face (a plane, or a spline lying in one) now asks for no size. An edge between
flat faces is one segment; an edge beside a curved face still takes the curved face's
size, so both faces use the same nodes and the mesh stays watertight. Curved faces keep
the cap, the aspect limit and the grid limits.

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

* Mesh counts of a box follow the flat-face mesher: engine export, viewport box and edges

The box exports as 12 triangles (was 124), draws as 12 glass triangles (was 104) and
its imported-mesh outline is 12 segments a box (was 20). Also cargo fmt.

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

* Tests: side-wall sag checks pick the wall by face, the dense-cluster test keeps its intent

The flat caps of a cylinder are their rim alone now, so a triangle with all corners on the
rim is no proof of a wall triangle: pick the wall's triangles by their face. The hole
placement test asserted the body had twice the face's triangles; it asserts the body has
other faces than the one picked.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:25:08 +00:00
54 changed files with 3556 additions and 184 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelle
- Model decisions (what an operation does, which bodies it touches, which face or edge a point or name means, depths, nearness) use exact geometry; a mesh is for display, export, previews and mesh bodies (`xtask/mesh-decisions.txt` only shrinks): `cargo xtask mesh-decisions`.
- New kernel cases go to solidcore: `cargo xtask lists`.
- Everything is a command and programmatic calls never open dialogs: `skills/solidcraft-commands/SKILL.md`.
- Layering (geom, sketch/kernel/render, doc, io, engine, ui-egui, apps); only `crates/kernel` uses truck: `cargo xtask layers`.
- Layering; only `crates/kernel` uses truck; solidcore only via `solidcore-ops`/`-tess`/`-record` (`xtask/solidcore-seam.txt`): `cargo xtask layers`.
- One outer UI container, one camera path (`navigation::handle`), colours from theme tokens: `cargo xtask ui-containers`.
- Application code is Rust only and builds for wasm: `cargo xtask wasm`, `cargo xtask web-offline-test`.
- The product name is SolidCraft; the old name lives only in `docs/compatibility.md`.
+8 -8
View File
@@ -1250,10 +1250,10 @@
"cmd.panel.resize_pair": "تحريك فاصل اللوحة",
"cmd.panel.sash.down": "تحريك فاصل اللوحة لأسفل",
"cmd.panel.sash.left": "تحريك فاصل اللوحة لليسار",
"cmd.panel.sash.reset": "إعادة تعيين فاصل اللوحة المحدد",
"cmd.panel.sash.reset": "إعادة ضبط فاصل اللوحة التي عليها التركيز",
"cmd.panel.sash.right": "تحريك فاصل اللوحة لليمين",
"cmd.panel.sash.up": "تحريك فاصل اللوحة لأعلى",
"cmd.panel.toggle": "تبديل طي اللوحة المحددة",
"cmd.panel.toggle": "طي اللوحة التي عليها التركيز أو توسيعها",
"cmd.parameters_add": "إضافة معامل مستخدم",
"cmd.parameters_change": "تغيير المعاملات",
"cmd.parameters_comment": "التعليق على المعامل",
@@ -1510,7 +1510,7 @@
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "متصفح التصيير",
"cmd.view.render_browser": "مستعرض التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
"cmd.view.render_focus": "عرض تركيز التصيير",
"cmd.view.render_live": "إيقاف التصيير المباشر مؤقتًا أو استئنافه",
@@ -2853,7 +2853,7 @@
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
"prefs.selecting": "التحديد",
"prefs.shared_panel_layout": "استخدام تخطيط اللوحات المشترك",
"prefs.shared_panel_layout_help": "استخدم المتصفح والسجل والمساعد داخل لوحات قابلة للنقل. يسري التغيير بعد تطبيق التفضيلات.",
"prefs.shared_panel_layout_help": "اعرض المستعرض والسجل والمساعد الحالية في لوحات قابلة للنقل. يسري التغيير عند تطبيق التفضيلات.",
"prefs.shift": "Shift",
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
"prefs.shift_add_note": "عند التشغيل: Shift يضيف، وCtrl / Cmd يبدّل. عند الإيقاف (الافتراضي): كلاهما يبدّل.",
@@ -3614,7 +3614,7 @@
"render.flare_streak": "خط ضوئي بأسلوب أنامورفي",
"render.flare_strength": "شدة وهج الإضاءات العالية",
"render.flare_threshold": "عتبة الوهج الخطية للمشهد",
"render.fold_browser": "طيّ المتصفح",
"render.fold_browser": "طيّ المستعرض",
"render.frame_aspect": "النسبة",
"render.frame_centre_mark": "علامة المركز",
"render.frame_copy_limit": "لا يوجد اسم متاح للقطة مكررة",
@@ -3844,7 +3844,7 @@
"render.light_gpu_punctual": "الأضواء النقطية والكشّافة متاحة مع تتبّع الأشعة على المعالج.",
"render.light_hard_shadow": "لهذا الضوء ظلال حادة؛ نعومة المصدر غير متاحة.",
"render.light_intensity": "الشدة",
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المتصفح.",
"render.light_missing": "هذا الضوء لم يعد موجودًا. اختر ضوءًا في المستعرض.",
"render.light_place_highlight": "وضع بالتمييز",
"render.light_place_pending": "الوضع بالتمييز يتطلب اختيار الأضواء في منطقة العرض وهو غير مدعوم بعد.",
"render.light_position": "الموضع",
@@ -4457,7 +4457,7 @@
"render.transmission_strata_help": "القيمة 1 تحافظ على التدفق الأصلي. 2 أو 4 مسارات فرعية تقسّم أول اختيار انعكاس أو نفاذ في الزجاج إلى طبقات مع بقاء قانوني سنل والامتصاص دون تغيير. المزيد من المسارات يكلف عملًا أكثر؛ وهذا ليس إزالة تشويش ولا دعمًا للأوساط المتداخلة ولا وعدًا بتصيير أسرع.",
"render.tree_add_light": "إضافة ضوء",
"render.tree_add_shot": "إضافة لقطة",
"render.tree_browser": "متصفح التصيير",
"render.tree_browser": "مستعرض التصيير",
"render.tree_by_look": "حسب المظهر",
"render.tree_by_object": "حسب الكائن",
"render.tree_constant": "بيئة ثابتة",
@@ -5889,7 +5889,7 @@
"ui.project": "إسقاط",
"ui.project_face_edges_sketch": "إسقاط حواف الوجه في رسومات جديدة",
"ui.project_references_construction": "إسقاط المراجع كهندسة مساعدة",
"ui.project_references_construction_note": "ينشئ الإسقاط والتقاطع وتضمين الهندسة ثلاثية الأبعاد وحواف الوجه لرسم جديد على وجه هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
"ui.project_references_construction_note": "تصبح الهندسة التي تنشئها أوامر «إسقاط» و«تقاطع» و«تضمين هندسة ثلاثية الأبعاد»، وحواف الوجه في رسم جديد على وجه، هندسةً مساعدة: مراجع للرسم عليها لا تدخل في المقاطع. وتبقى مرتبطة بالنموذج.",
"ui.properties": "الخصائص",
"ui.properties.copy_matrix": "نسخ المصفوفة",
"ui.properties.first_moments": "العزوم الأولى",
+624 -6
View File
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -3,7 +3,7 @@
use std::collections::BTreeMap;
use crate::persistent_name::{Base, FaceName, FeatureId};
use crate::persistent_name::{Base, EdgeName, FaceName, FeatureId};
use crate::{FeatureKind, PlaneRef};
/// Remap persistent topology tokens without rewriting arbitrary user expressions or labels.
@@ -18,9 +18,20 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
}
// Imported names belong to the complete canonical body prefix, even when that prefix
// happens to contain text resembling a feature token.
if let Some((body, suffix)) = name.rsplit_once(":face:")
&& let Some(new) = bodies.get(body)
let body_face = name.rsplit_once(":face:").and_then(|(body, suffix)| Some((bodies.get(body)?, body, suffix)));
// An edge name (`A|B`) is remapped one face at a time, so a body face on one side doesn't
// stop the other side's feature or body from being remapped (#13097). A body whose own
// name holds a `|` is a single face, not an edge.
if !body_face.is_some_and(|(_, body, _)| body.contains('|'))
&& let Ok(edge) = EdgeName::parse(name)
&& edge.faces.len() > 1
&& edge.to_string() == name
{
let faces: Vec<String> = edge.faces.iter().map(|face| remap_topology(&face.to_string(), ids, bodies)).collect();
let tie = edge.tie.map(|k| format!("~{k}")).unwrap_or_default();
return format!("{}{tie}", faces.join("|"));
}
if let Some((new, _, suffix)) = body_face {
return format!("{new}:face:{suffix}");
}
// A name that reads is remapped by its structure: every feature it names, and a body
@@ -410,6 +421,19 @@ mod clone_tests {
assert_eq!(remap_topology("UserF1:label", &ids, &bodies), "UserF1:label");
}
#[test]
fn an_edge_name_with_a_body_face_maps_every_face() {
// #13097: a body face on either side of an edge must not stop the other side's
// feature or body from being remapped.
let ids: BTreeMap<u64, u64> = [(2, 20)].into_iter().collect();
let bodies: BTreeMap<String, String> =
[("Plate".to_string(), "Plate (copy)".to_string()), ("A|B".to_string(), "A|B (copy)".to_string())].into_iter().collect();
assert_eq!(remap_topology("Plate:face:3|F2:top", &ids, &bodies), "Plate (copy):face:3|F20:top");
assert_eq!(remap_topology("F2:top|Plate:face:3", &ids, &bodies), "F20:top|Plate (copy):face:3");
assert_eq!(remap_topology("Plate:face:3|F2:top~1", &ids, &bodies), "Plate (copy):face:3|F20:top~1");
// A body whose name holds a `|` is still one face.
assert_eq!(remap_topology("A|B:face:0", &ids, &bodies), "A|B (copy):face:0");
}
#[test]
fn a_sheet_face_follows_its_body_and_its_flange() {
let ids = BTreeMap::from([(1, 2), (10, 20)]);
let bodies = BTreeMap::from([("Body1".into(), "Body1 (copy)".into())]);
+6 -1
View File
@@ -176,7 +176,12 @@ mod tests {
let sag = mesh
.triangles
.iter()
.map(|t| t.map(|i| mesh.positions[i as usize]))
.zip(&mesh.tri_face)
// The side wall's own triangles: the flat caps have corners on the rim too.
.filter(|(_, f)| {
matches!(solidcraft_kernel::face_surface(&mb.body, **f as usize), Some(solidcraft_kernel::FaceSurface::Cylinder { .. }))
})
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
.fold(0.0, f64::max);
+36 -8
View File
@@ -149,8 +149,14 @@ fn resolve_uncached(
LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => {
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
// A mesh body is one face with no edges: the flat patch of triangles picked,
// traced (#13464).
if target.body.is_mesh() {
crate::project3d::flat_outline(&mesh, q).iter().flat_map(|outline| fit_mesh_outline(plane, outline)).collect()
} else {
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
}
}
}
}
@@ -1308,8 +1314,8 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
}
/// A circle in space (centre, unit normal, radius) through the polyline's points, and whether
/// the polyline closes.
fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
/// the polyline closes. Its points lie on it within `within`, by default 1e-7 of the radius.
fn circle3(pts: &[Vec3], within: Option<f64>) -> Option<(Vec3, Vec3, f64, bool)> {
let n = pts.len();
if n < 4 {
return None;
@@ -1326,15 +1332,15 @@ fn circle3(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64, bool)> {
let centre = a + (nrm.cross(ab) * ac.len2() + ac.cross(nrm) * ab.len2()) / (2.0 * l2);
let r = centre.dist(a);
let un = nrm.normalized()?;
let tol = 1e-7 * r.max(1.0);
let tol = within.unwrap_or(1e-7 * r.max(1.0));
pts.iter().all(|p| (p.dist(centre) - r).abs() < tol && (*p - centre).dot(un).abs() < tol).then_some((centre, un, r, closed))
}
/// A circle seen at a slant is an ellipse, exactly: a full circle becomes an ellipse; an arc
/// becomes conics (rational quadratic Béziers of at most 90 degrees each — parallel projection
/// keeps their weights).
fn tilted_circle(plane: &Plane, pts: &[Vec3]) -> Option<Vec<LinkGeom>> {
let (c, n, r, closed) = circle3(pts)?;
fn tilted_circle(plane: &Plane, pts: &[Vec3], within: Option<f64>) -> Option<Vec<LinkGeom>> {
let (c, n, r, closed) = circle3(pts, within)?;
let sn = plane.normal();
let cosang = n.dot(sn).abs();
if !(1e-9..=1.0 - 1e-12).contains(&cosang) {
@@ -1429,9 +1435,31 @@ fn plane_trace(sk: &Plane, other: &Plane) -> Option<LinkGeom> {
Some(LinkGeom::Line(p0 - u * AXIS_HALF, p0 + u * AXIS_HALF))
}
/// Most corners a mesh face's outline has and is still taken for a polygon.
const MESH_POLYGON_CORNERS: usize = 12;
/// The outline of a mesh body's flat patch (a closed loop of its corners) as lines and arcs. Any
/// triangle's corners and any rectangle's lie on one circle, so only a loop of more corners than
/// [`MESH_POLYGON_CORNERS`], all on one circle, is a tessellated circle, an ellipse on a slanted
/// plane; and the sides of a polygon stay lines, never one fitted spline (#13464). A mesh file
/// keeps its corners in single precision, so they lie on the circle within about 1e-7 of their
/// size, not of the circle's.
fn fit_mesh_outline(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
let within = pts.iter().map(|p| p.len()).fold(1.0, f64::max) * 1e-6;
if pts.len() > MESH_POLYGON_CORNERS + 1
&& let Some(e) = tilted_circle(plane, pts, Some(within))
{
return e;
}
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
let mut out = Vec::new();
fit_chain(&p2, None, &mut out);
out
}
/// Project a world polyline and fit lines and arcs to it.
fn fit_world(plane: &Plane, pts: &[Vec3]) -> Vec<LinkGeom> {
if let Some(e) = tilted_circle(plane, pts) {
if let Some(e) = tilted_circle(plane, pts, None) {
return e;
}
let p2: Vec<Vec2> = pts.iter().map(|p| plane.to_local(*p)).collect();
+128
View File
@@ -278,6 +278,134 @@ fn ray_hit(o: Vec3, d: Vec3, t: &[Vec3; 3]) -> Option<f64> {
Some(e2.dot(q) * inv)
}
/// Most triangles [`flat_outline`] looks through.
const FLAT_OUTLINE_TRIANGLES: usize = 2_000_000;
/// The outline of the flat patch of a mesh body's triangles at `at`: the triangle nearest `at`
/// and every triangle joined to it edge to edge on its plane, traced as closed loops, each with its
/// first point again at its end. An imported mesh is one face, so this flat patch is what a pick
/// on one of its faces means (#13464). Empty when the mesh has no triangle or is too large.
pub(crate) fn flat_outline(m: &Mesh, at: Vec3) -> Vec<Vec<Vec3>> {
use std::collections::{HashMap, HashSet, VecDeque};
if m.triangles.is_empty() || m.triangles.len() > FLAT_OUTLINE_TRIANGLES {
return Vec::new();
}
let size = m.bounds().diagonal().max(1e-9);
// Corners at one place are one corner: an STL repeats each corner for every triangle.
let weld = size * 1e-9;
let mut ids: HashMap<(i64, i64, i64), usize> = HashMap::new();
let mut corners: Vec<Vec3> = Vec::new();
let mut tri: Vec<[usize; 3]> = Vec::with_capacity(m.triangles.len());
for t in &m.triangles {
let Some(ps) = m.tri(t) else { continue };
let mut out = [0; 3];
for (o, p) in out.iter_mut().zip(ps) {
if !p.is_finite() {
return Vec::new();
}
let key = ((p.x / weld).round() as i64, (p.y / weld).round() as i64, (p.z / weld).round() as i64);
*o = *ids.entry(key).or_insert_with(|| {
corners.push(p);
corners.len() - 1
});
}
tri.push(out);
}
let at_of = |t: &[usize; 3]| t.map(|i| corners.get(i).copied().unwrap_or_default());
let normal = |t: &[usize; 3]| {
let [a, b, c] = at_of(t);
(b - a).cross(c - a).normalized()
};
// The triangle nearest the pick, and its plane.
let Some(seed) = (0..tri.len()).filter(|i| tri.get(*i).and_then(normal).is_some()).min_by(|x, y| {
let d = |i: &usize| {
tri.get(*i).map_or(f64::INFINITY, |t| {
let [a, b, c] = at_of(t);
crate::project::closest_on_triangle(at, a, b, c).dist(at)
})
};
d(x).total_cmp(&d(y))
}) else {
return Vec::new();
};
let Some((n0, p0)) = tri.get(seed).and_then(|t| Some((normal(t)?, at_of(t)[0]))) else { return Vec::new() };
let flat = |t: &[usize; 3]| normal(t).is_some_and(|n| n.dot(n0) > 1.0 - 1e-6) && at_of(t).iter().all(|p| (*p - p0).dot(n0).abs() <= size * 1e-6);
// Triangles by the edges they share.
let mut by_edge: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
for (i, t) in tri.iter().enumerate() {
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
by_edge.entry((a.min(b), a.max(b))).or_default().push(i);
}
}
// The flat patch: from the seed across shared edges onto triangles on its plane.
let mut patch: HashSet<usize> = HashSet::from([seed]);
let mut queue = VecDeque::from([seed]);
while let Some(i) = queue.pop_front() {
let Some(t) = tri.get(i) else { continue };
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
for j in by_edge.get(&(a.min(b), a.max(b))).into_iter().flatten() {
if !patch.contains(j) && tri.get(*j).is_some_and(flat) {
patch.insert(*j);
queue.push_back(*j);
}
}
}
}
// Its outline: the edges only one of its triangles has, each the way its triangle runs.
let mut next: HashMap<usize, Vec<usize>> = HashMap::new();
for i in &patch {
let Some(t) = tri.get(*i) else { continue };
for k in 0..3 {
let (a, b) = (t[k], t[(k + 1) % 3]);
let inside = by_edge.get(&(a.min(b), a.max(b))).map_or(0, |ts| ts.iter().filter(|j| patch.contains(j)).count());
if inside == 1 {
next.entry(a).or_default().push(b);
}
}
}
let mut loops = Vec::new();
let mut starts: Vec<usize> = next.keys().copied().collect();
starts.sort_unstable();
for start in starts {
while let Some(mut v) = next.get_mut(&start).and_then(Vec::pop) {
let mut pts: Vec<Vec3> = [start, v].iter().filter_map(|i| corners.get(*i).copied()).collect();
let mut steps = 0;
while v != start && steps < patch.len() * 3 {
let Some(w) = next.get_mut(&v).and_then(Vec::pop) else { break };
pts.extend(corners.get(w).copied());
v = w;
steps += 1;
}
if v == start && pts.len() >= 4 {
loops.extend(turning_corners(&pts, size * 1e-9));
}
}
}
loops
}
/// A closed loop (its first point again at its end) without the corners where it runs straight
/// on: a mesh's outline often has a corner part way along a side, where a triangle of the patch
/// meets it. None when fewer than three corners turn.
fn turning_corners(pts: &[Vec3], tol: f64) -> Option<Vec<Vec3>> {
let ring = pts.get(..pts.len().saturating_sub(1))?;
let n = ring.len();
let turns = |i: usize| {
let (prev, here, next) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n));
let (Some(prev), Some(here), Some(next)) = (prev, here, next) else { return false };
let (a, b) = (*here - *prev, *next - *here);
a.cross(b).len() > tol * a.len().max(b.len()).max(1.0)
};
let mut out: Vec<Vec3> = (0..n).filter(|i| turns(*i)).filter_map(|i| ring.get(i).copied()).collect();
if out.len() < 3 {
return None;
}
out.extend(out.first().copied());
Some(out)
}
/// Project a world polyline onto face `f` along `dir` (either way, nearest hit). Points that
/// miss the face split the result.
pub(crate) fn onto_face(m: &Mesh, f: u32, pts: &[Vec3], dir: Vec3) -> Vec<Vec<Vec3>> {
+83 -24
View File
@@ -12,6 +12,7 @@ use solidcraft_geom::prelude::*;
use super::{CommandSpec, not_in_sketch};
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
use crate::schema::features::SweepParams;
use crate::schema::features::{
AppliedRadius, BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams,
FeatureNotice, FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams,
@@ -27,6 +28,7 @@ const SPHERE_CONTRACT: TypedContract<SphereParams> = TypedContract::new();
const TORUS_CONTRACT: TypedContract<TorusParams> = TypedContract::new();
const CONE_CONTRACT: TypedContract<ConeParams> = TypedContract::new();
const PIPE_CONTRACT: TypedContract<PipeParams> = TypedContract::new();
const SWEEP_CONTRACT: TypedContract<SweepParams> = TypedContract::new();
const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContract::new();
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
const COMBINE_CONTRACT: TypedContract<CombineParams> = TypedContract::new();
@@ -47,9 +49,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | {type: world, axis: X|Y|Z} (as returned by timeline.inspect) | construction axis name (followed when edited) | {origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
CommandSpec::new("solid.sweep", "Sweep", crate::checked!("solid.sweep", sweep))
.at("SOLID", "CREATE")
.icon("sweep")
.contract(&SWEEP_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, touching?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, touching?, targets? (Join excludes hidden bodies from automatic targets; explicit targets or participants may include them), name?, body_name?"),
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
@@ -118,7 +122,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; where faces of several bodies meet there, the face of a body in `targets`; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole, drilled with the tap maker's tabulated drill: ISO metric (a custom metric size: major minus pitch), UNC/UNF, G, Rp, Rc 1/16 to 4, NPT 1/16 to 3; a `diameter` given with it chooses another drill, from the thread's minor diameter to 5% over the tap maker's) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); head?: \"ISO 4762\" (socket head) | \"ISO 7380-1\" (button head) (with clearance: a counterbore the screw's head sits in, so type defaults to counterbore; its diameter is the head's dk plus head_gap each side, its depth the head's k plus head_gap; a cb_diameter or cb_depth given keeps its value); head_gap?: length (default 0.5 mm; an expression with parameters is kept, so the recess follows them); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?, bridge?: length (counterbore: two 3D-printing bridge layers this thick below its floor, a slot along X then a square; e.g. 0.2 mm); cs_diameter?, cs_angle?; thread?: \"M6\", \"1/4-20 UNC\", \"G 1/4\", \"Rp 1/4\", \"Rc 1/4\" or \"1/4-18 NPT\" (cosmetic unless modeled); hand?: right|left (the tapped thread's hand; default right; needs thread); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); modeled?: bool (needs thread: the tapped thread cut into the wall, the groove between its teeth, as solid.thread's modelled one; default false: listed only); ease_out?: expr|standard|short (modelled tapped threads: the inner end, towards the bottom, fades out over this length, ISO 3508's runout, standard 2.5 P, short 1.25 P; without it the groove stops square at thread_depth, or clear of a blind hole's bottom); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
CommandSpec::new("solid.pipe", "Pipe", crate::checked!("solid.pipe", pipe))
.at("SOLID", "CREATE")
.icon("pipe")
@@ -2947,7 +2951,7 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
const CLEARANCE_FITS: [&str; 6] = ["close", "fine", "normal", "medium", "loose", "coarse"];
/// A value typed by the user, cut short for a message.
fn brief(text: &str) -> String {
pub(super) fn brief(text: &str) -> String {
const MAX: usize = 32;
match text.char_indices().nth(MAX) {
Some((end, _)) => format!("{}…", &text[..end]),
@@ -2991,6 +2995,35 @@ fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
}
}
/// The counterbore a cap screw's head sits in (`head` with `clearance`): its diameter and depth.
/// The head's dk and k come from the standard's table (ISO 4762 socket heads, ISO 7380-1 button
/// heads, in `parts`), with `head_gap` (default 0.5 mm) added each side and below. A gap that
/// names parameters is kept as an expression, so the recess follows them. `None` without `head`.
fn head_recess(s: &Session, cmd: &str, p: &Value) -> Result<Option<(Expr, Expr)>> {
let head = match p.get("head") {
None | Some(Value::Null) => return Ok(None),
Some(Value::String(h)) => h.trim(),
Some(_) => return Err(bad(cmd, "`head` must be a text: `ISO 4762` (socket head) or `ISO 7380-1` (button head)")),
};
let size = str_(p, "clearance").ok_or_else(|| bad(cmd, "`head` needs `clearance`, the screw size the head belongs to (e.g. \"M4\")"))?;
let (dk, k) = super::parts::cap_screw_head(head, size).map_err(|e| bad(cmd, e))?;
let gap = match (expr(p, "head_gap"), p.get("head_gap")) {
(Some(g), _) => g,
(None, None | Some(Value::Null)) => Expr::new("0.5 mm"),
(None, v) => return Err(bad(cmd, format!("`head_gap` {}", expr_problem(v)))),
};
let g = s.doc.eval(gap.as_str(), Kind::Length).map_err(|e| bad(cmd, format!("head_gap: {e}")))?;
if !(0.0..=dk).contains(&g) {
return Err(bad(cmd, format!("`head_gap` must be 0 to {dk} mm (the head's diameter), not {g}")));
}
let mm = |x: f64| format!("{} mm", (x * 1e6).round() / 1e6);
Ok(Some(if solidcraft_doc::expr::references(gap.as_str()).is_empty() {
(Expr::new(mm(dk + 2.0 * g)), Expr::new(mm(k + g)))
} else {
(Expr::new(format!("{dk} mm + 2 * ({})", gap.as_str())), Expr::new(format!("{k} mm + ({})", gap.as_str())))
}))
}
/// The pitches to try for a plain `M<d>` that has no ISO coarse pitch, `None` for anything else.
fn fine_only_hint(t: &str) -> Option<String> {
let diameter = t.trim().strip_prefix(['M', 'm']).and_then(|d| d.trim().parse::<f64>().ok());
@@ -3214,18 +3247,28 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
(None, None | Some(Value::Null)) => None,
(None, v) => return Err(bad(cmd, format!("`bridge` {}", expr_problem(v)))),
};
let kind = match str_(p, "type").unwrap_or("simple") {
// A counterbore sized to the screw's head: `head` with `clearance`.
let head = head_recess(s, cmd, p)?;
if head.is_some() && str_(p, "type").is_some_and(|t| t != "counterbore") {
return Err(bad(cmd, "`head` needs `type: counterbore` (the default with `head`); a countersink for a flat head is not supported yet"));
}
let kind = match str_(p, "type").unwrap_or(if head.is_some() { "counterbore" } else { "simple" }) {
"simple" => match expr(p, "tip_angle") {
Some(t) if depth.is_some() => solidcraft_doc::HoleKind::Drilled { tip_angle: t },
_ => solidcraft_doc::HoleKind::Simple,
},
"drilled" => solidcraft_doc::HoleKind::Drilled { tip_angle: expr(p, "tip_angle").unwrap_or_else(|| "118 deg".into()) },
"counterbore" => solidcraft_doc::HoleKind::Counterbore {
cb_diameter: get("cb_diameter")?,
cb_depth: get("cb_depth")?,
tip_angle: expr(p, "tip_angle"),
bridge: bridge.clone(),
},
"counterbore" => {
// Sized to the screw's head unless given; a value given, even a bad one, is checked.
let given = |k: &str| !matches!(p.get(k), None | Some(Value::Null));
let (cb_diameter, cb_depth) = match &head {
Some((d, h)) => {
(if given("cb_diameter") { get("cb_diameter")? } else { d.clone() }, if given("cb_depth") { get("cb_depth")? } else { h.clone() })
}
None => (get("cb_diameter")?, get("cb_depth")?),
};
solidcraft_doc::HoleKind::Counterbore { cb_diameter, cb_depth, tip_angle: expr(p, "tip_angle"), bridge: bridge.clone() }
}
"countersink" => solidcraft_doc::HoleKind::Countersink {
cs_diameter: get("cs_diameter")?,
// The default only when no angle is given: a supplied one out of range is refused (#9625).
@@ -3522,17 +3565,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
let orientation = match p.get("orientation") {
None | Some(Value::Null) => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
Some(v) => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
if !(d.len() > 1e-9) {
return Err(bad(cmd, "the `orientation` direction must not be zero"));
}
SweepOrientation::Direction(Dir(d))
}
Some(v) => sweep_orientation(v).map_err(|e| bad(cmd, e))?,
};
let twist = match p.get("twist") {
None | Some(Value::Null) => None,
@@ -3560,6 +3593,24 @@ pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepO
Ok((orientation, twist, scale))
}
/// A sweep's `orientation` as given (not `null`): perpendicular or parallel (any case), or a
/// non-zero direction `[x, y, z]`, `{x, y, z}` or `{direction: …}`. The error is the message.
pub(crate) fn sweep_orientation(v: &Value) -> std::result::Result<SweepOrientation, &'static str> {
match v {
Value::String(o) if o.eq_ignore_ascii_case("perpendicular") => Ok(SweepOrientation::Perpendicular),
Value::String(o) if o.eq_ignore_ascii_case("parallel") => Ok(SweepOrientation::Parallel),
v => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or("`orientation` must be perpendicular, parallel or a direction [x, y, z]")?;
if !(d.len() > 1e-9) {
return Err("the `orientation` direction must not be zero");
}
Ok(SweepOrientation::Direction(Dir(d)))
}
}
}
/// A sweep's guide `rails` and `rail_fit` (solid.sweep and surface.sweep), checked: one or two
/// rails, each a sketch (default: the path's) and 1…1000 of its curves; a fit of scale or
/// stretch; and no orientation, twist or scale besides, since the rails set those.
@@ -3580,9 +3631,7 @@ pub(super) fn sweep_rails(s: &Session, p: &Value, cmd: &str, path_sketch: u64) -
pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default_sketch: Option<u64>) -> Result<(Vec<SweepRail>, RailFit)> {
let rail_fit = match p.get("rail_fit") {
None | Some(Value::Null) => RailFit::Scale,
Some(Value::String(f)) if f.eq_ignore_ascii_case("scale") => RailFit::Scale,
Some(Value::String(f)) if f.eq_ignore_ascii_case("stretch") => RailFit::Stretch,
Some(_) => return Err(bad(cmd, "`rail_fit` must be scale or stretch")),
Some(v) => rail_fit_form(v).map_err(|e| bad(cmd, e))?,
};
let given = match p.get("rails") {
None | Some(Value::Null) => return Ok((Vec::new(), rail_fit)),
@@ -3608,6 +3657,16 @@ pub(super) fn guide_rails(s: &Session, p: &Value, cmd: &str, what: &str, default
Ok((rails, rail_fit))
}
/// Guide rails' `rail_fit` as given (not `null`): scale or stretch, in any case. The error is
/// the message.
pub(crate) fn rail_fit_form(v: &Value) -> std::result::Result<RailFit, &'static str> {
match v {
Value::String(f) if f.eq_ignore_ascii_case("scale") => Ok(RailFit::Scale),
Value::String(f) if f.eq_ignore_ascii_case("stretch") => Ok(RailFit::Stretch),
_ => Err("`rail_fit` must be scale or stretch"),
}
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
+26 -11
View File
@@ -23,6 +23,8 @@ use solidcraft_io::Format;
use super::CommandSpec;
use super::print_face;
use crate::params::{bad, bool_, num, str_, string_list};
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
@@ -40,15 +42,19 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
CommandSpec::new("file.save", "Save", save)
CommandSpec::new("file.save", "Save", crate::checked!("file.save", save))
.icon("save")
.key("Ctrl+S")
.noundo()
.contract(&SAVE_CONTRACT)
.output(output_schema_for::<SaveResult>)
.params("path? (default: current file; a name that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (replace the design's file even though another program or computer changed it since it was opened or saved; without it that save is refused)"),
CommandSpec::new("file.save_as", "Save As", save_as)
CommandSpec::new("file.save_as", "Save As", crate::checked!("file.save_as", save_as))
.icon("save")
.key("Ctrl+Shift+S")
.noundo()
.contract(&SAVE_AS_CONTRACT)
.output(output_schema_for::<SaveResult>)
.params("path (a design file name, .sc as a rule; one that opens as another kind of file, such as .step, .stl or .dxf, is refused: file.export writes those); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false); overwrite?: bool (as file.save, when `path` is the design's own file)"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore")
.noundo()
@@ -122,6 +128,8 @@ fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"name": name}))
}
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
@@ -682,7 +690,15 @@ fn write_bytes(
s.disk = solidcraft_io::design_lock::stamp(path);
s.read_only = false;
s.mark_saved();
let mut out = json!({"path": path, "bytes": bytes.len(), "name": s.doc.name});
let mut out = SaveResult {
path: path.to_string(),
bytes: bytes.len(),
name: s.doc.name.clone(),
kept_format: None,
warnings: None,
not_kept: None,
param_warnings: Vec::new(),
};
// A newer SolidCraft's file keeps what this version can't read, and its format number. What
// couldn't go back, because the design was changed around it, is said.
if s.doc.newer_file.is_some() {
@@ -690,13 +706,13 @@ fn write_bytes(
if format == "json" && s.doc.newer_file.as_ref().is_some_and(|file| file.needs_container()) {
lost.push("the parts of the file beside the design, which plain JSON has no room for: save as .sc to keep them".to_string());
}
out["kept_format"] = json!(s.doc.format);
out.kept_format = Some(s.doc.format.clone());
if !lost.is_empty() {
out["warnings"] = json!(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect::<Vec<_>>());
out["not_kept"] = json!(lost);
out.warnings = Some(lost.iter().map(|what| format!("Not kept: {what}. The previous file is `{path}.bak`.")).collect());
out.not_kept = Some(lost);
}
}
Ok(out)
serde_json::to_value(out).map_err(|e| bad("file.save", e.to_string()))
}
impl Session {
@@ -1394,12 +1410,11 @@ mod tests {
assert!(bytes.starts_with(signature), "{cmd}");
}
// The triangles `file.export` writes (the 80-byte header holds the file's name), 84 + 50
// bytes each. The mesher lays each planar face out as a regular grid whose sides are at
// most a fifth of the body's diagonal (kernel test `a_box_is_a_regular_grid_on_every_face`):
// 22.9 / 5 = 4.58 mm, so 5 × 3 × 2 cells along 20 × 10 × 5, two triangles each.
// bytes each. A planar face is exact for any triangulation of its outline, so the mesher
// gives each of the box's six faces two triangles (kernel test `a_box_is_two_triangles_a_face`).
s.execute("file.export", &json!({"path": at("box.stl")})).unwrap();
assert_eq!(std::fs::read(at("StlBinary.txt")).unwrap()[80..], std::fs::read(at("box.stl")).unwrap()[80..]);
let triangles = 2 * 2 * (5 * 3 + 5 * 2 + 3 * 2);
let triangles = 6 * 2;
let stl = std::fs::read(at("box.stl")).unwrap();
assert_eq!(u32::from_le_bytes([stl[80], stl[81], stl[82], stl[83]]), u32::try_from(triangles).unwrap());
assert_eq!(stl.len(), 84 + 50 * triangles);
+2 -1
View File
@@ -45,6 +45,7 @@ pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_object, edge_ref_form, explicit_plane, plane_name_problem, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub(crate) use features::{rail_fit_form, sweep_orientation};
pub use file::open_steps::OpenSteps;
pub(crate) use inspect::color_param;
pub use join_pieces::{Addition, addition};
@@ -131,7 +132,7 @@ mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
pub(crate) use sketch_project::{RefForm, ref_form};
pub(crate) use sketch_project::{MAX_PROJECTED_ENTITIES, RefForm, model_ref, ref_form};
mod sketch_store;
mod surface;
mod surface_analysis;
+21
View File
@@ -343,6 +343,27 @@ fn metric(size: &str) -> Option<(f64, f64)> {
METRIC.iter().find(|(s, _, _)| s.eq_ignore_ascii_case(size)).map(|(_, d, p)| (*d, *p))
}
/// The head a counterbore seats, for `solid.hole`'s `head`: (head diameter dk, head height k)
/// of a metric cap screw, by its standard (`ISO 4762`, `ISO 7380-1`) or the family name
/// `parts.insert` takes. The size is written as `clearance` takes it (`M4`, or `M4x0.7` with the
/// pitch). The error names what is missing: the standard, or the size in it.
pub(crate) fn cap_screw_head(standard: &str, size: &str) -> std::result::Result<(f64, f64), String> {
let (name, table): (&str, &[(&str, f64, f64, f64, f64)]) = match standard.trim() {
s if s.eq_ignore_ascii_case("ISO 4762") || s.eq_ignore_ascii_case("socket_head_cap_screw") => ("ISO 4762", &SHCS),
s if s.eq_ignore_ascii_case("ISO 7380-1") || s.eq_ignore_ascii_case("button_head_cap_screw") => ("ISO 7380-1", &BHCS),
other => {
return Err(format!("no head standard `{}`: ISO 4762 (socket head) or ISO 7380-1 (button head)", super::features::brief(other)));
}
};
let size = size.trim().split(['x', 'X']).next().unwrap_or("").trim();
let (first, last) = (table.first().map_or("", |r| r.0), table.last().map_or("", |r| r.0));
table
.iter()
.find(|r| r.0.eq_ignore_ascii_case(size))
.map(|r| (r.1, r.2))
.ok_or_else(|| format!("no {name} head for `{}`: {first} to {last}", super::features::brief(size)))
}
fn default_length(d: f64) -> f64 {
LENGTHS.iter().copied().find(|l| *l >= 2.5 * d).unwrap_or(100.0)
}
+76 -25
View File
@@ -14,6 +14,7 @@ use crate::schema::sketch::{
EditResult, EqualMade, EqualParams, EqualResult, ExitParams, FinishResult, FixOutcome, FixParams, FixResult, HorizontalVerticalParams,
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentMade, TangentParams, TangentResult,
};
use crate::schema::sketch_dimension::{DimensionResult, SketchDimensionParams};
use crate::schema::sketch_session::{
Activated, Attachment, ChainParams, ChainResult, ConstructionParams, ConstructionResult, CreateParams, CreateResult, DeleteParams, DeleteResult,
DimensionTextParams, DimensionTextResult, DragEndParams, DragEndResult, DragParams, DragResult, Frame, MovePointParams, MovePointResult,
@@ -22,7 +23,8 @@ use crate::schema::sketch_session::{
use crate::schema::sketch_shapes::{
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, Created, DrawResult, LineParams,
PointParams, PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectCorners,
RectResult, RectRoundedParams, RectSides, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
RectResult, RectRoundedParams, RectSides, RectThreePointParams, RectTwoPointParams, RoundedRectResult, SlotCenterToCenterParams,
SlotOverallParams,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
@@ -76,10 +78,13 @@ const COLLINEAR_CONTRACT: TypedContract<CollinearParams> = TypedContract::new();
const SYMMETRY_CONTRACT: TypedContract<SymmetryParams> = TypedContract::new();
const LINE_CONTRACT: TypedContract<LineParams> = TypedContract::new();
const POINT_CONTRACT: TypedContract<PointParams> = TypedContract::new();
const DIMENSION_CONTRACT: TypedContract<SketchDimensionParams> = TypedContract::new();
const CIRCLE_CENTER_CONTRACT: TypedContract<CircleCenterParams> = TypedContract::new();
const CIRCLE_TWO_POINT_CONTRACT: TypedContract<CircleTwoPointParams> = TypedContract::new();
const CIRCLE_THREE_POINT_CONTRACT: TypedContract<CircleThreePointParams> = TypedContract::new();
const ARC_THREE_POINT_CONTRACT: TypedContract<ArcThreePointParams> = TypedContract::new();
const SLOT_CENTER_TO_CENTER_CONTRACT: TypedContract<SlotCenterToCenterParams> = TypedContract::new();
const SLOT_OVERALL_CONTRACT: TypedContract<SlotOverallParams> = 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();
@@ -205,18 +210,29 @@ pub static COMMANDS: &[CommandSpec] = &[
.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)
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", crate::checked!("sketch.slot.center_to_center", slot_c2c))
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.contract(&SLOT_CENTER_TO_CENTER_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
CommandSpec::new("sketch.slot.overall", "Overall Slot", crate::checked!("sketch.slot.overall", slot_overall))
.at("SKETCH", "CREATE")
.icon("slot_overall")
.enabled(in_sketch)
.contract(&SLOT_OVERALL_CONTRACT)
.output(output_schema_for::<DrawResult>)
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", crate::checked!("sketch.point", draw_point)).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).contract(&POINT_CONTRACT).output(output_schema_for::<PointResult>).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
CommandSpec::new("sketch.dimension", "Sketch Dimension", crate::checked!("sketch.dimension", dimension))
.at("SKETCH", "CREATE")
.icon("dimension")
.key("D")
.enabled(in_sketch)
.contract(&DIMENSION_CONTRACT)
.output(output_schema_for::<DimensionResult>)
.params(
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|major|minor|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them (between the perpendiculars through them, or a tenth of their distance past) is the distance between them; pulled further past an end, up or down from it horizontal, sideways vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically, anywhere else (inside it or past its corners) the distance between them. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An ellipse: a half-axis, the distance from the centre to an axis end (major or minor; with auto, text_at pulled across the major axis gives the major half-axis, pulled out past an end of it the minor one, the minor without text_at; previous_axis?: major|minor, the half-axis an earlier placement chose, kept while text_at is near the boundary between them; the reply's axis names the one chosen; with auto, when a driving dimension already holds one half-axis (or its whole axis), the other one, wherever text_at is; without its major axis line the major half-axis goes to the ellipse's own major end, without its minor axis line the minor half-axis is the minor radius); any other type, radius among them, the minor radius; an ellipse's axis line with auto: its length, the whole axis, wherever text_at is; half_axes?: bool (default true; false: with auto, the minor radius wherever text_at is, as before half-axes, which the Dimension tool sends with Draw ellipses with their axes off). An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends the Sketch preference, on by default): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", crate::checked!("sketch.constraint.horizontal_vertical", c_horizontal_vertical))
@@ -2053,7 +2069,7 @@ fn polygon_edge(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result<Value> {
let (out, info) = slot_parts(s, p, cmd, c0, c1, w)?;
Ok(result(out, info))
draw_reply(out, info)
}
/// What [`slot`] adds: the curve and constraint ids, and the sketch's solve.
@@ -2797,8 +2813,11 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let id = p.get(key).and_then(Value::as_str).unwrap_or_default();
bad(cmd, format!("`{id}` is not the end of one curve: pick a curve, or the end point of a line or an arc"))
};
let mut before = None;
let mut residual = None;
let (out, info) = edit(s, p, cmd, true, |sk, _| {
let rep = solve(&mut sk.clone());
before = Some(rep.dof);
let (pa, pb) = (tangent_end(sk, p, "a"), tangent_end(sk, p, "b"));
let ca = if pa.is_none() { Some(line_or_axis_ref(sk, p.get("a"), cmd, "a")?) } else { None };
let cb = if pb.is_none() { Some(line_or_axis_ref(sk, p.get("b"), cmd, "b")?) } else { None };
@@ -2827,6 +2846,12 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
}
let tangent = add_c(sk, ConstraintKind::Tangent { a, b })?;
ids.push(tangent.clone());
// As in `constrain`: what already holds needs nothing moved, and is then redundant when it
// removes no degree of freedom (see `redundancy`).
residual = ids.iter().try_fold(0.0_f64, |worst, id| residual_of(sk, id).map(|r| worst.max(r)));
if residual.is_some_and(|r| r <= solidcraft_sketch::CONSISTENT) {
return Ok((tangent, ids));
}
// A line's end and a curve, in either order: only that end moves, so the line turns about
// its other end and gets longer or shorter to meet the curve (#12092). At a joint that end
// can't leave the other curve: the curve it ends stays, and the other curve moves. Otherwise,
@@ -2848,7 +2873,12 @@ fn tangent_at_end(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
Ok((tangent, ids))
})?;
let (constraint, constraints) = out;
reply(cmd, TangentResult { made: TangentMade::End { constraint, constraints, sketch: info }, param_warnings: Vec::new() })
let by = redundancy(s, p, cmd, before, &info, residual, &constraint)?;
if let Some(by) = &by {
keep_redundant(p, cmd, by)?;
}
let made = TangentMade::End { constraint, constraints, sketch: info, redundant: by.as_ref().map(|_| true), determined_by: by };
reply(cmd, TangentResult { made, param_warnings: Vec::new() })
}
fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("curves").is_some() {
@@ -3229,6 +3259,11 @@ pub(super) fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> {
}
fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
reply("sketch.dimension", dimension_result(s, p)?)
}
/// [`dimension`]'s reply, as the typed value the output schema describes.
fn dimension_result(s: &mut Session, p: &Value) -> Result<DimensionResult> {
let cmd = "sketch.dimension";
// A dimension may measure model geometry (#7912): an `{edge: [x,y,z], body?}` or a
// `{vertex: [x,y,z], body?}` among its entities is projected into the sketch first, as
@@ -3578,11 +3613,22 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
// Report what the dimension measures once the sketch is solved, not the geometry before.
let current = solved_measure(s, p, cmd).unwrap_or(current);
if driven {
let mut out = json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info});
if let Some(axis) = axis {
out["axis"] = json!(axis);
}
return Ok(out);
return Ok(DimensionResult {
param: None,
driven: Some(true),
kind: kind_name.to_string(),
expression: None,
measured: current.0,
unit: current.1.to_string(),
sketch: info,
scaled: None,
axis: axis.map(str::to_string),
defined: None,
redundant: None,
determined_by: None,
created: None,
param_warnings: Vec::new(),
});
}
// A dimension the sketch already determines cannot drive it: it is refused, or with
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
@@ -3597,9 +3643,9 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
o.remove("if_redundant");
o.remove("value");
}
let mut out = dimension(s, &q)?;
out["redundant"] = json!(true);
out["determined_by"] = json!(by);
let mut out = dimension_result(s, &q)?;
out.redundant = Some(true);
out.determined_by = Some(by);
return Ok(out);
}
return Err(bad(
@@ -3608,17 +3654,22 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
let mut out = json!({"param": param, "type": kind_name, "expression": v, "measured": current.0, "unit": current.1, "sketch": info});
if let Some(factor) = scaled {
out["scaled"] = json!(round6(factor));
}
if let Some(axis) = axis {
out["axis"] = json!(axis);
}
if let Some((name, _)) = defines {
out["defined"] = json!(name);
}
Ok(out)
Ok(DimensionResult {
param: Some(param),
driven: None,
kind: kind_name.to_string(),
expression: Some(v),
measured: current.0,
unit: current.1.to_string(),
sketch: info,
scaled: scaled.map(round6),
axis: axis.map(str::to_string),
defined: defines.map(|(name, _)| name),
redundant: None,
determined_by: None,
created: None,
param_warnings: Vec::new(),
})
}
/// A dimension's measured value as reported, in the unit its expression is written in.
+5 -3
View File
@@ -428,8 +428,10 @@ fn add_link_unless_present(
if skip_present && r.wires.is_empty() && sk.link_geometry_present(&r.geom) {
return Ok(None);
}
// A mesh body is one face whose name says nothing of the flat patch picked: the link keeps
// the point instead (#13464), as an edge of a mesh body keeps no name.
let name = if let LinkSource::Face { body, at } = &r.source {
st.body(body).and_then(|body| {
st.body(body).filter(|body| !body.body.is_mesh()).and_then(|body| {
if let Some(index) = r.face_index {
solidcraft_doc::naming::face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
} else {
@@ -582,7 +584,7 @@ pub(super) fn project_operands(s: &mut Session, p: &Value, cmd: &str, keys: &[&s
/// A model's reference among a command's operands (#6348): an `{edge: [x,y,z], body?}` or a
/// `{vertex: [x,y,z], body?}`.
fn model_ref(v: &Value) -> bool {
pub(crate) fn model_ref(v: &Value) -> bool {
v.get("edge").is_some() || v.get("vertex").is_some()
}
@@ -601,7 +603,7 @@ fn project_ref(s: &mut Session, doc: &Document, id: u64, sk: &mut Sketch, v: &Va
/// Most model references one call may project into a sketch: each one is a link and a resolve,
/// so the count is capped before any of them is projected.
const MAX_PROJECTED_ENTITIES: usize = 1_000;
pub(crate) const MAX_PROJECTED_ENTITIES: usize = 1_000;
/// A dimension's entities may be model geometry (#7912): an `{edge: [x,y,z], body?}` or
/// `{vertex: [x,y,z], body?}` among them is projected into the sketch first, as Project does,
@@ -1803,3 +1803,87 @@ fn projected_references_can_start_as_construction() {
run(&mut s, "sketch.project", json!({"refs": [{"edge": [40, 15, 20]}], "construction": false}));
assert_eq!(flags(&mut s), vec![true, false]);
}
/// #13464: an imported mesh is one face with no edges, so Project on one of its faces projected
/// nothing. The pick now means the flat patch of triangles there: a box's top projects as its
/// rectangle, its side seen edge-on as a line, and the link keeps the pick's point (a mesh's face
/// name says nothing of the patch), so the sketch still builds once finished.
#[test]
fn a_face_of_an_imported_mesh_projects_its_flat_patch() {
let dir = std::env::temp_dir().join(format!("solidcraft-project-mesh-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let stl = dir.join("box.stl");
let mut src = Session::default();
run(&mut src, "solid.box", json!({"length": 20, "width": 10, "height": 5}));
run(&mut src, "file.export_stl", json!({"path": stl.to_string_lossy()}));
let mut s = Session::default();
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
let body = s.model.state().bodies[0].name.clone();
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 20}));
run(&mut s, "sketch.project", json!({"refs": [{"body": body, "face": [10, 5, 5]}]}));
let si = inspect(&mut s);
let lines = linked_curves(&si);
assert_eq!(lines.len(), 4, "{si}");
let mut corners: Vec<(i64, i64)> = lines
.iter()
.flat_map(|c| [&c["start_at"], &c["end_at"]])
.map(|p| ((p[0].as_f64().unwrap() * 1e6).round() as i64, (p[1].as_f64().unwrap() * 1e6).round() as i64))
.collect();
corners.sort_unstable();
corners.dedup();
assert_eq!(corners, [(0, 0), (0, 10_000_000), (20_000_000, 0), (20_000_000, 10_000_000)], "{si}");
assert!(s.active_sketch.is_some());
run(&mut s, "sketch.finish", json!({}));
let failing: Vec<_> = s.model.results.iter().filter_map(|r| r.error.clone()).collect();
assert!(failing.is_empty(), "{failing:?}");
// A side seen edge-on: the line it makes.
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 20}));
run(&mut s, "sketch.project", json!({"refs": [{"body": body, "face": [10, 0, 2.5]}]}));
assert_eq!(linked_curves(&inspect(&mut s)).len(), 1);
std::fs::remove_dir_all(&dir).ok();
}
/// #13464: on a sketch plane at a slant to the face, a flat patch's polygon keeps its straight
/// sides. A rectangle's four corners and a triangle's three always lie on one circle, so they must
/// not be taken for a circle seen at a slant (an ellipse); a tessellated circle still is one.
#[test]
fn a_mesh_face_keeps_its_straight_sides_on_a_slanted_plane() {
let dir = std::env::temp_dir().join(format!("solidcraft-project-mesh-slant-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut src = Session::default();
run(&mut src, "solid.box", json!({"length": 20, "width": 10, "height": 5, "body_name": "box"}));
run(&mut src, "sketch.create", json!({"plane": "XY"}));
run(&mut src, "sketch.line", json!({"points": [[30, 0], [42, 0], [34, 9], [30, 0]]}));
run(&mut src, "sketch.finish", json!({}));
let sketch = src.doc.features.last().unwrap().id;
run(&mut src, "solid.extrude", json!({"sketch": sketch, "distance": 5, "body_name": "tri"}));
run(&mut src, "solid.cylinder", json!({"base": [60, 0, 0], "radius": 7, "height": 5}));
let mut stls = Vec::new();
for body in src.model.state().bodies.iter().map(|b| b.name.clone()).collect::<Vec<_>>() {
let path = dir.join(format!("{body}.stl"));
run(&mut src, "file.export_stl", json!({"path": path.to_string_lossy(), "bodies": [body]}));
stls.push(path);
}
let mut s = Session::default();
for stl in &stls {
run(&mut s, "file.insert_mesh", json!({"path": stl.to_string_lossy()}));
}
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
run(&mut s, "construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": "30 deg", "name": "Tilt"}));
let kinds = |s: &mut Session, body: &str, at: [f64; 3]| -> Vec<String> {
run(s, "sketch.create", json!({"plane": "Tilt"}));
run(s, "sketch.project", json!({"refs": [{"body": body, "face": at}]}));
let kinds = linked_curves(&inspect(s)).iter().map(|c| c["type"].as_str().unwrap_or("").to_string()).collect();
run(s, "sketch.finish", json!({}));
kinds
};
assert_eq!(kinds(&mut s, &names[0], [10., 5., 5.]), ["line"; 4], "the box's top is a rectangle");
assert_eq!(kinds(&mut s, &names[1], [35., 3., 5.]), ["line"; 3], "the prism's top is a triangle");
let round = kinds(&mut s, &names[2], [60., 0., 5.]);
assert_eq!(round.len(), 1, "the cylinder's top is one curve: {round:?}");
assert_eq!(round[0], "ellipse", "the cylinder's round top, seen at a slant: {round:?}");
std::fs::remove_dir_all(&dir).ok();
}
+62
View File
@@ -397,6 +397,68 @@ fn tangent_at_a_joint_adds_only_the_tangent() {
}
}
/// Tangent on a line's end and a circle the end already touches tangentially, as for two curves
/// (`constrain`): what already holds moves nothing. With both fixed the tangent removes no degree
/// of freedom, so it is kept and reported as redundant, or refused with `if_redundant: "refuse"`,
/// leaving the sketch as it was.
#[test]
fn tangent_at_an_end_that_already_holds_is_redundant() {
let touching = |fixed: bool| {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[5, 3], [20, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 8], "radius": 5}));
if fixed {
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "c1"], "fixed": true}));
}
s
};
for (a, b) in [("l1.end", "c1"), ("c1", "l1.end")] {
let mut s = touching(false);
let (line, circle) = (snap(&s, "l1"), snap(&s, "c1"));
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert!(same(&snap(&s, "l1"), &line) && same(&snap(&s, "c1"), &circle), "{a}, {b}: something moved");
assert!(r.get("redundant").is_none(), "{a}, {b}: free, so not redundant: {r}");
let mut s = touching(true);
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert_eq!(r["redundant"], true, "{a}, {b}: {r}");
// Fixed entities are held, not constrained, so no constraint is listed.
assert!(r["determined_by"].is_array(), "{a}, {b}: {r}");
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
let mut s = touching(true);
let count = sketch(&s).constraints.len();
let e = s.execute("sketch.constraint.tangent", &json!({"a": a, "b": b, "if_redundant": "refuse"})).unwrap_err().to_string();
assert!(e.contains("already determined by"), "{a}, {b}: {e}");
assert_eq!(sketch(&s).constraints.len(), count, "{a}, {b}: nothing added");
}
}
/// The same with free curves whose own constraints already decide it: circle c2 is concentric
/// with c1 and equal to it, the line is tangent to c2 and its end lies on c2. Tangent on that end
/// and c1 adds the end on c1 and the tangent, and names what determines the tangent: at the end
/// it says the line is square to the radius there, so the shared centre and the tangent on c2
/// decide it, and the equal radius does not.
#[test]
fn tangent_at_an_end_names_the_constraints_that_determine_it() {
for (a, b) in [("l1.end", "c1"), ("c1", "l1.end")] {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[5, 3], [20, 3]]}));
run(&mut s, "sketch.circle.center", json!({"center": [20, 8], "radius": 5}));
run(&mut s, "sketch.circle.center", json!({"center": [40, 30], "radius": 2}));
let mut id = |cmd: &str, p: Value| run(&mut s, cmd, p)["constraint"].as_str().unwrap_or_default().to_string();
let concentric = id("sketch.constraint.concentric", json!({"a": "c1", "b": "c2"}));
id("sketch.constraint.equal", json!({"a": "c1", "b": "c2"}));
let tangent = id("sketch.constraint.tangent", json!({"a": "l1", "b": "c2"}));
id("sketch.constraint.coincident", json!({"a": "l1.end", "b": "c2"}));
let r = run(&mut s, "sketch.constraint.tangent", json!({"a": a, "b": b}));
assert_eq!(r["redundant"], true, "{a}, {b}: {r}");
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(2), "{a}, {b}: {r}");
let by = json!([format!("{concentric} (Concentric)"), format!("{tangent} (Tangent)")]);
assert_eq!(r["determined_by"], by, "{a}, {b}: {r}");
}
}
#[test]
fn a_fixed_second_pick_makes_the_first_move() {
let mut s = line_and_circle();
+5 -2
View File
@@ -14,7 +14,7 @@ use solidcraft_kernel::{Construction, Continuity};
use super::CommandSpec;
use super::features::{add_feature, axis_param, check_expr, extent_param, feature_sketch, operation, plane_param, profiles, sketch_id};
use crate::params::{bad, bool_, expr, num, str_, string_list, vec3};
use crate::schema::features::FeatureResult;
use crate::schema::features::{FeatureResult, SurfaceSweepParams};
use crate::schema::surface::{FairParams, OffsetParams, ReverseNormalParams, StitchParams, ThickenParams, UnstitchParams, UntrimParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
@@ -26,6 +26,7 @@ const THICKEN_CONTRACT: TypedContract<ThickenParams> = TypedContract::new();
const OFFSET_CONTRACT: TypedContract<OffsetParams> = TypedContract::new();
const UNTRIM_CONTRACT: TypedContract<UntrimParams> = TypedContract::new();
const FAIR_CONTRACT: TypedContract<FairParams> = TypedContract::new();
const SURFACE_SWEEP_CONTRACT: TypedContract<SurfaceSweepParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
@@ -60,9 +61,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SURFACE", "CREATE")
.icon("surface_revolve")
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); axis: sketch line id|x|y|X|Y|Z|{type: world, axis: X|Y|Z}|construction axis name|{origin, dir} (origin: numbers or length expressions, followed when parameters change) | {edge|face: [x,y,z]} (a straight or circular edge, or a cylinder, cone or torus face: about its axis, followed as the model changes); angle?: expr (default 360 deg); angle2?: expr (the other side); symmetric?: bool; name?, body_name? (a surface body, no caps; a solid where a full turn closes it)"),
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
CommandSpec::new("surface.sweep", "Surface Sweep", crate::checked!("surface.sweep", surface_sweep))
.at("SURFACE", "CREATE")
.icon("surface_sweep")
.contract(&SURFACE_SWEEP_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale?, rails?, rail_fit? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
.at("SURFACE", "CREATE")
+28
View File
@@ -483,6 +483,16 @@ fn cases() -> Vec<Case> {
vec![spot([20.0, 15.0, 20.0], DOWN, 20.0, None)],
)
.faces(1),
// A counterbore for the screw's head: ISO 4762 M4 (dk 7, k 4) plus the 0.5 mm gap.
Case::new(
"type/counterbore-head-m4/through",
block(),
json!({"position": [20, 15, 20], "clearance": "M4", "head": "ISO 4762", "name": "H"}),
cyl(4.0, 4.5) + cyl(2.25, 15.5),
2.25,
vec![spot([20.0, 15.0, 20.0], DOWN, 20.0, None)],
)
.faces(3),
// Extents: to an object.
Case::new(
"extent/to-face/bottom",
@@ -1023,6 +1033,24 @@ fn a_refused_hole_names_the_value_at_fault() {
assert!(e.contains("`fit` must be close, normal or loose, not `tight`") && !e.contains("M1.6"), "{e}");
let e = refused(json!({"clearance": "M100"}));
assert!(e.contains("no clearance hole for `M100`") && e.contains("M1.6 to M64"), "{e}");
// A counterbore for a screw's head: the standard, the size in it, the screw size, the type
// and the gap are each named when they are at fault.
let e = refused(json!({"clearance": "M4", "head": "DIN 912"}));
assert!(e.contains("no head standard `DIN 912`") && e.contains("ISO 4762") && e.contains("ISO 7380-1"), "{e}");
let e = refused(json!({"clearance": "M2.5", "head": "ISO 7380-1"}));
assert!(e.contains("no ISO 7380-1 head for `M2.5`") && e.contains("M3 to M16"), "{e}");
let e = refused(json!({"diameter": 4.5, "head": "ISO 4762"}));
assert!(e.contains("`head` needs `clearance`"), "{e}");
let e = refused(json!({"clearance": "M4", "head": "ISO 4762", "type": "countersink", "cs_diameter": 9}));
assert!(e.contains("`head` needs `type: counterbore`"), "{e}");
let e = refused(json!({"clearance": "M4", "head": "ISO 4762", "head_gap": -1}));
assert!(e.contains("`head_gap` must be 0 to 7 mm") && e.contains("not -1"), "{e}");
let e = refused(json!({"clearance": "M4", "head": "ISO 4762", "head_gap": "width"}));
assert!(e.contains("head_gap:"), "{e}");
let e = refused(json!({"clearance": "M4", "head": "ISO 4762", "cb_depth": true}));
assert!(e.contains("`cb_depth` must be a number or an expression"), "{e}");
let e = refused(json!({"clearance": "M4", "head": 4762}));
assert!(e.contains("`head` must be a text"), "{e}");
for angle in [json!(0), json!(200), json!(-90), json!(0.1)] {
let e = refused(json!({"diameter": 3, "type": "countersink", "cs_diameter": 6, "cs_angle": angle}));
assert!(e.contains("the countersink angle must be more than 0.115° and less than 180°") && !e.contains("wider"), "{angle}: {e}");
+104
View File
@@ -361,6 +361,110 @@ pub struct PipeParams {
pub common: FeatureCommon,
}
/// Sweep sketch profiles along a path of sketch curves into a solid.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SweepParams {
/// The sketch holding the profiles, by id or name. Default: the active sketch, else the last
/// one.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "IdOrName")]
pub sketch: Option<IdOrName>,
/// Which of the sketch's profiles to sweep. Default: all of them.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ProfilesParam")]
pub profiles: Option<ProfilesParam>,
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
#[serde(default)]
pub exact_regions: Option<bool>,
/// The sketch holding the path, by id or name.
pub path_sketch: SketchRef,
/// The path's curve ids, in order along the path.
pub path: StringList,
#[serde(flatten, deserialize_with = "feature_common")]
pub shape: SweepShape,
/// How the sweep combines with existing bodies. Default: new.
#[serde(default)]
pub operation: Option<OperationParam>,
/// The bodies a join, cut or intersect works on; given, they win over `participants`.
/// Default: `participants`, else for a join from a sketch on a body's face that body, else
/// every body in the active component (an intersect removes those the sweep doesn't meet),
/// or for a cut or intersect where it has none, every body.
#[serde(default)]
pub targets: Option<StringList>,
/// What a join makes of pieces that share no face. Default: separate.
#[serde(default)]
pub touching: Option<TouchingParam>,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// Sweep sketch curves or profile boundaries along a path of sketch curves into a surface body:
/// walls only, no caps. A path with corners, or of 3D curves, gives ruled walls mitred at the
/// corners. `participants` changes nothing here.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SurfaceSweepParams {
/// The sketch holding the curves or profiles, by id or name. Default: the active sketch, else
/// the last one.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "IdOrName")]
pub sketch: Option<IdOrName>,
/// Curve ids to sweep, at most 1000: open or closed chains of curves meeting end to end.
#[serde(default)]
pub curves: Option<StringList>,
/// Which of the sketch's profiles to sweep the boundaries of. Default: all of them when no
/// `curves` are given, else none.
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ProfilesParam")]
pub profiles: Option<ProfilesParam>,
/// Each index or point pick is exactly the region it names. Without it, a pick on a drawn
/// profile that reference geometry (a face's projected outline) cuts into regions takes the
/// whole drawn profile. The dialogs send `true`: they highlight the regions they pick.
#[serde(default)]
pub exact_regions: Option<bool>,
/// The sketch holding the path, by id or name.
pub path_sketch: SketchRef,
/// The path's curve ids, in order along the path: 1 to 1000.
pub path: StringList,
#[serde(flatten, deserialize_with = "feature_common")]
pub shape: SweepShape,
#[serde(flatten, deserialize_with = "feature_common")]
pub common: FeatureCommon,
}
/// How a sweep's profile turns and changes size along the path, as `features::sweep_options`
/// and `features::sweep_rails` read it (solid.sweep and surface.sweep), flattened. Guide rails
/// set the turn and the size themselves: with `rails`, the handler refuses `orientation`,
/// `twist` and `scale`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SweepShape {
/// How the profile turns as it follows the path. Default: perpendicular.
#[serde(default)]
pub orientation: Option<super::types::SweepOrientationParam>,
/// How far the profile turns about the path over its length, from none at the start: within
/// four turns (1440 deg) either way. Default: none.
#[serde(default)]
#[schemars(with = "Option<AngleExpr>")]
pub twist: Option<super::types::SweepTwist>,
/// The profile's scale at the end of the path, changing linearly along it from 1 at the
/// start: 0.01 to 100. Default: 1.
#[serde(default)]
pub scale: Option<NumberExpr>,
/// Guide rails, each meeting every plane square to the path once along it, followed from the
/// end nearest the path's start. One rail turns the profile so that its point where the rail
/// starts stays on the rail, and scales it with the rail's distance from the path; two rails
/// carry it between them, its points where they start staying on both. The path must have no
/// sharp corners.
#[serde(default)]
pub rails: Option<super::types::SweepRails>,
/// How the rails reshape the profile. Default: scale. Used only with `rails`.
#[serde(default)]
pub rail_fit: Option<super::types::RailFitParam>,
}
/// Add a box around bodies, larger by a margin on every side.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
+316
View File
@@ -6,6 +6,7 @@ use super::{
BoundingSolidParams, BoxParams, ChamferParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult,
FilletParams, FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
};
use super::{SurfaceSweepParams, SweepParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
@@ -309,6 +310,321 @@ fn combine_replies_with_combine_result() {
}
}
#[test]
fn sweep_schemas_and_the_key_check_agree() {
let shape = ["orientation", "rail_fit", "rails", "scale", "twist"];
let mut solid = vec![
"body_name",
"body_names",
"exact_regions",
"name",
"operation",
"participants",
"path",
"path_sketch",
"profiles",
"sketch",
"targets",
"touching",
];
solid.extend(shape);
solid.sort_unstable();
assert_keys(input_schema_for::<SweepParams>(), &solid);
let mut surface = vec!["body_name", "body_names", "curves", "exact_regions", "name", "participants", "path", "path_sketch", "profiles", "sketch"];
surface.extend(shape);
surface.sort_unstable();
assert_keys(input_schema_for::<SurfaceSweepParams>(), &surface);
}
/// The calls both sweeps refuse today, as `sweep` and `surface_sweep` read them, each run on
/// `sketch_rectangle`'s sketch (`Sketch1`, which is the path sketch too: the path's curves are
/// only looked up when the feature is evaluated).
#[test]
fn sweep_strict_keeps_todays_errors() {
let rail = json!([{"curves": ["l1"]}]);
let mut cases = Vec::new();
for extra in [
json!({"orientation": "sideways"}),
json!({"orientation": " parallel"}),
json!({"orientation": 5}),
json!({"orientation": [0, 0, 0]}),
json!({"orientation": [1, 0]}),
json!({"orientation": {"direction": [0, 0, 0]}}),
json!({"orientation": {"direction": "x"}}),
json!({"orientation": {"x": 1, "y": 0}}),
json!({"twist": true}),
json!({"twist": " "}),
json!({"twist": [10]}),
json!({"twist": 1e9}),
json!({"twist": "no_such_parameter"}),
json!({"twist": "1441 deg"}),
json!({"scale": true}),
json!({"scale": 1e9}),
json!({"scale": "x".repeat(4097)}),
json!({"scale": 0}),
json!({"scale": "no_such_parameter"}),
json!({"rail_fit": "wobble"}),
json!({"rail_fit": 1}),
json!({"rail_fit": "stretch "}),
json!({"rails": "l1"}),
json!({"rails": {"curves": ["l1"]}}),
json!({"rails": []}),
json!({"rails": [{"curves": ["l1"]}, {"curves": ["l2"]}, {"curves": ["l3"]}]}),
json!({"rails": [{}]}),
json!({"rails": [5]}),
json!({"rails": [{"curves": []}]}),
json!({"rails": [{"curves": null}]}),
json!({"rails": [{"curves": 5}]}),
json!({"rails": [{"curves": ["l1", 2]}]}),
json!({"rails": [{"curves": vec!["l1"; 1001]}]}),
json!({"rails": [{"curves": vec!["l1"; 10_001]}]}),
json!({"rails": [{"sketch": true, "curves": ["l1"]}]}),
json!({"rails": [{"sketch": [], "curves": ["l1"]}]}),
json!({"rails": [{"curves": ["l1"]}, {"sketch": {}, "curves": ["l2"]}]}),
json!({"rails": [{"sketch": "Missing", "curves": ["l1"]}]}),
json!({"rails": rail, "orientation": "parallel"}),
json!({"rails": rail, "twist": "10 deg"}),
json!({"rails": rail, "scale": 2}),
json!({"path_sketch": true}),
json!({"path_sketch": null}),
json!({"path_sketch": "Missing"}),
json!({"path": []}),
json!({"sketch": true}),
json!({"sketch": null}),
json!({"sketch": "Missing"}),
json!({"profiles": []}),
json!({"profiles": [true]}),
json!({"profiles": [{"point": [1]}]}),
json!({"profiles": null}),
json!({"participants": ["Missing"]}),
] {
for id in ["solid.sweep", "surface.sweep"] {
let mut p = json!({"path_sketch": "Sketch1", "path": ["l1"]});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
cases.push((id, p));
}
}
cases.extend([
("solid.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "operation": "bogus"})),
("solid.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "operation": "auto"})),
("surface.sweep", json!({"path_sketch": "Sketch1", "path": vec!["l1"; 1001]})),
("surface.sweep", json!({"path_sketch": "Sketch1", "path": ["l1"], "curves": vec!["l1"; 1001]})),
]);
assert_strict_keeps_todays_errors(&cases);
}
/// A circle `C` (radius 10, curve `c`) and a line `L` across it (`l`) on XY; a path `P` on XZ, a
/// line 40 up the Z axis (`up`), with a rail beside it (`in`) from the circle's edge in to x = 5
/// at the top; a block `Block` the sweep cuts a quarter from, and a plate `Plate` inside it.
fn sweep_rig(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("sketch.create", json!({"plane": "XY", "name": "C"})),
("sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"})),
("sketch.finish", json!({})),
("sketch.create", json!({"plane": "XY", "name": "L"})),
("sketch.line", json!({"points": [[-10, 0], [10, 0]], "ids": ["l"]})),
("sketch.finish", json!({})),
("sketch.create", json!({"plane": "XZ", "name": "P"})),
// The XZ sketch's y runs down world Z.
("sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]})),
("sketch.line", json!({"points": [[10, 0], [5, -40]], "ids": ["in"]})),
("sketch.finish", json!({})),
("solid.box", json!({"corner": [0, 0, 0], "length": 30, "width": 30, "height": 10, "body_name": "Block"})),
("solid.box", json!({"corner": [-3, -3, 20], "length": 6, "width": 6, "height": 5, "body_name": "Plate"})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
s
}
/// Every form a sweep call takes, each command with its own.
fn sweep_calls() -> Vec<(&'static str, Value)> {
let on = |p: Value| {
let mut call = json!({"path_sketch": "P", "path": ["up"]});
call.as_object_mut().into_iter().for_each(|o| o.extend(p.as_object().cloned().unwrap_or_default()));
call
};
let solid = [
json!({"sketch": "C", "name": "A", "body_name": "A"}),
json!({"sketch": "C", "profiles": "all", "exact_regions": true, "path": "up", "orientation": "Parallel", "body_names": ["B"]}),
json!({"sketch": "C", "profiles": [0], "orientation": [1, 0, 0], "twist": "90 deg", "scale": 0.5, "operation": "new"}),
json!({"sketch": "C", "orientation": {"direction": {"x": 1, "y": 0, "z": 0}}, "twist": 45, "scale": "1.5"}),
json!({"sketch": "C", "orientation": "perpendicular", "twist": null, "scale": null, "rails": null, "rail_fit": null, "touching": null}),
json!({"sketch": "C", "rails": [{"curves": ["in"]}]}),
json!({"sketch": "C", "rails": [{"sketch": "P", "curves": "in"}], "rail_fit": "Stretch"}),
json!({"sketch": "C", "rails": [{"sketch": null, "curves": ["in"]}], "rail_fit": "scale"}),
json!({"sketch": "C", "operation": "join", "targets": ["A"], "touching": "one_body", "participants": []}),
json!({"sketch": "C", "operation": "cut", "targets": "Block", "name": "Cut"}),
json!({"sketch": "C", "operation": "intersect", "participants": ["Plate"]}),
];
let surface = [
json!({"sketch": "L", "curves": ["l"], "body_name": "W"}),
json!({"sketch": "L", "curves": "l", "orientation": [1, 0, 0], "twist": "30 deg", "scale": 2, "body_names": ["X"]}),
json!({"sketch": "C", "profiles": [0], "exact_regions": false, "orientation": "PARALLEL"}),
json!({"sketch": "C", "rails": [{"sketch": "P", "curves": ["in"]}], "rail_fit": "stretch", "name": "Railed"}),
json!({"sketch": "C", "profiles": "all", "orientation": null, "rails": null, "participants": []}),
];
let mut calls: Vec<(&'static str, Value)> = solid.into_iter().map(|p| ("solid.sweep", on(p))).collect();
calls.extend(surface.into_iter().map(|p| ("surface.sweep", on(p))));
calls
}
/// `solid.sweep` and `surface.sweep` reply with `FeatureResult` in each of their forms, their
/// contracts taking each: in Warn a contract that refused one would still run it, so the replies
/// are checked for warnings.
#[test]
fn sweep_replies_match_the_output_schema_and_forms_are_accepted() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let mut s = sweep_rig(ParamCheck::Warn);
for (id, p) in sweep_calls() {
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
}
// Warn reports what the contracts refuse and runs the call anyway.
for id in ["solid.sweep", "surface.sweep"] {
let typo = s.execute(id, &json!({"sketch": "C", "path_sketch": "P", "path": ["up"], "twistt": 10})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(typo["param_warnings"], json!(["unknown key `twistt`; did you mean `twist`?"]), "{id}");
assert!(conforms(schema, schema, &typo), "{typo}");
}
}
/// The check changes nothing about the replies: each call gives the same JSON with the check off
/// and in strict (`ms`, the time it took, apart), and its keys are the ones the commands always
/// answered with.
#[test]
fn sweep_replies_keep_their_old_shape_whatever_the_check() {
let (mut off, mut strict) = (sweep_rig(ParamCheck::Off), sweep_rig(ParamCheck::Strict));
for (id, p) in sweep_calls() {
let run = |s: &mut Session| {
let mut r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
let keys: BTreeSet<String> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
assert!(r["ms"].is_null() || r["ms"].is_number(), "{r}");
r["ms"] = Value::Null;
(r, keys)
};
let (a, a_keys) = run(&mut off);
let (b, _) = run(&mut strict);
assert_eq!(a, b, "{id} {p}");
let old = ["bodies", "changed_bodies", "created_bodies", "feature", "ms", "name", "removed_bodies", "warning"];
assert!(old.iter().all(|k| a_keys.contains(*k)), "{a}");
assert!(a_keys.iter().all(|k| old.contains(&k.as_str()) || matches!(k.as_str(), "component" | "notice")), "{a}");
}
}
/// What the handlers ignore or read as left out, strict refuses; off and warn run it as before.
#[test]
fn sweep_wrong_typed_keys_are_reported() {
let mut cases: Vec<(&str, Value, Value)> = Vec::new();
for id in ["solid.sweep", "surface.sweep"] {
for (key, value) in
[("exact_regions", json!(1)), ("name", json!(5)), ("body_name", json!(5)), ("colour", json!("red")), ("orient", json!("parallel"))]
{
cases.push((id, json!({ key: value }), json!(1)));
}
// A misspelt key inside a rail: the handler reads the rail as in the path's sketch.
cases.push((id, json!({"rails": [{"curves": ["in"], "skecth": "P"}]}), json!(1)));
}
cases.push(("solid.sweep", json!({"operation": 5}), json!(1)));
// The surface sweep doesn't combine bodies: its handler leaves these alone.
cases.push(("surface.sweep", json!({"operation": "join", "targets": ["Block"]}), json!(2)));
cases.push(("surface.sweep", json!({"touching": "one_body"}), json!(1)));
for (id, extra, warnings) in cases {
let mut p = json!({"sketch": "C", "path_sketch": "P", "path": ["up"]});
p.as_object_mut().into_iter().for_each(|o| o.extend(extra.as_object().cloned().unwrap_or_default()));
let mut off = sweep_rig(ParamCheck::Off);
let r = off.execute(id, &p);
assert!(r.is_ok(), "{id} {p}: {r:?}");
let mut warn = sweep_rig(ParamCheck::Warn);
let r = warn.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
assert_eq!(r["param_warnings"].as_array().map(Vec::len), warnings.as_u64().map(|n| n as usize), "{id} {p}: {r}");
let mut strict = sweep_rig(ParamCheck::Strict);
assert!(strict.execute(id, &p).is_err(), "{id} {p}");
}
}
/// The sweep value types take what the handlers' readers take, and their schemas say the same,
/// apart from what a schema can't say (case, a zero direction, the rail curves' 10 000 cap).
#[test]
fn sweep_values_match_the_handler_readers() {
use crate::schema::types::{RailFitParam, SweepOrientationParam, SweepRails, SweepTwist};
fn accepts<T: serde::de::DeserializeOwned>(v: &Value) -> bool {
serde_json::from_value::<T>(v.clone()).is_ok()
}
let orientation = input_schema_for::<SweepOrientationParam>();
for (v, accepted, schema) in [
(json!("perpendicular"), true, true),
(json!("parallel"), true, true),
(json!("Parallel"), true, false),
(json!(" parallel"), false, false),
(json!("sideways"), false, false),
(json!([0, 0, 1]), true, true),
(json!({"x": 0, "y": 1, "z": 0}), true, true),
(json!({"direction": [1, 0, 0]}), true, true),
(json!({"direction": {"x": 1, "y": 0, "z": 0}, "extra": 1}), true, true),
(json!([0, 0, 0]), false, true),
(json!({"direction": [0, 0, 0]}), false, true),
(json!([0, 1]), false, false),
(json!([0, 0, 1e9]), false, false),
(json!({"direction": "x"}), false, false),
(json!({}), false, false),
(json!(5), false, false),
(json!(true), false, false),
] {
let reader = crate::cmd::sweep_orientation(&v).is_ok();
assert_eq!(accepts::<SweepOrientationParam>(&v), reader, "{v}");
assert_eq!(reader, accepted, "{v}");
assert_eq!(conforms(orientation.as_value(), orientation.as_value(), &v), schema, "schema {v}");
}
let fit = input_schema_for::<RailFitParam>();
for (v, accepted, schema) in [
(json!("scale"), true, true),
(json!("stretch"), true, true),
(json!("STRETCH"), true, false),
(json!("stretch "), false, false),
(json!("wobble"), false, false),
(json!(1), false, false),
] {
let reader = crate::cmd::rail_fit_form(&v).is_ok();
assert_eq!(accepts::<RailFitParam>(&v), reader, "{v}");
assert_eq!(reader, accepted, "{v}");
assert_eq!(conforms(fit.as_value(), fit.as_value(), &v), schema, "schema {v}");
}
for (v, accepted) in
[(json!(10), true), (json!("90 deg"), true), (json!(" a / 2 "), true), (json!(" "), false), (json!(true), false), (json!(1e9), false)]
{
let want = crate::params::expr(&json!({ "v": v.clone() }), "v");
assert_eq!(serde_json::from_value::<SweepTwist>(v.clone()).ok().map(|t| t.0), want, "{v}");
assert_eq!(want.is_some(), accepted, "{v}");
}
let rails = input_schema_for::<SweepRails>();
for (v, accepted) in [
(json!([{"curves": ["a"]}]), true),
(json!([{"curves": "a"}]), true),
(json!([{"sketch": "R", "curves": ["a", "b"]}, {"sketch": 3, "curves": ["c"]}]), true),
(json!([{"sketch": null, "curves": ["a"]}]), true),
(json!([{"curves": vec!["a"; 1000]}]), true),
(json!([{"curves": vec!["a"; 1001]}]), false),
(json!([]), false),
(json!([{"curves": ["a"]}, {"curves": ["b"]}, {"curves": ["c"]}]), false),
(json!([{}]), false),
(json!([{"curves": []}]), false),
(json!([{"curves": [1]}]), false),
(json!([{"sketch": true, "curves": ["a"]}]), false),
(json!([{"curves": ["a"], "skecth": "R"}]), false),
(json!(["a"]), false),
(json!({"curves": ["a"]}), false),
(json!("a"), false),
] {
assert_eq!(accepts::<SweepRails>(&v), accepted, "{v}");
assert_eq!(conforms(rails.as_value(), rails.as_value(), &v), accepted, "schema {v}");
}
let issue = serde_json::from_value::<SweepRails>(json!([{"curves": ["a"]}, {"curves": ["b"], "skecth": "R"}])).err().map(|e| e.to_string());
assert!(issue.is_some_and(|e| e.ends_with("unknown key `skecth` in `rails[1]`: a rail takes `sketch` and `curves`")));
}
#[test]
fn feature_result_serializes_like_the_old_reply() {
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
+1 -1
View File
@@ -88,7 +88,7 @@ fn describe_returns_both_schemas() {
// Its own output schema lists the warnings warn mode can add.
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
let d = s.execute("engine.describe", &json!({"command": "solid.emboss"})).unwrap_or_else(|e| panic!("{e}"));
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
+2
View File
@@ -104,6 +104,7 @@ pub mod render_shots;
pub mod render_stage;
pub mod render_surface_resources;
pub mod render_ui;
pub mod save;
pub mod section;
pub mod select;
pub mod sheet;
@@ -113,6 +114,7 @@ pub mod sketch_3d;
pub mod sketch_clipboard;
pub mod sketch_constraints;
pub mod sketch_create;
pub mod sketch_dimension;
pub mod sketch_ends;
pub mod sketch_equation;
pub mod sketch_import;
+139
View File
@@ -0,0 +1,139 @@
//! Contracts and result types for the commands in `cmd/file.rs` that save the design:
//! `file.save` and `file.save_as` (#13307).
//!
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
//! reads it. `file.save_as` refuses a missing, blank or over-long `path` with its own message,
//! so there `path` is optional in the type and required in the schema. `format` and
//! `compression` are text in the type: the handler refuses a word it doesn't know, whichever of
//! the two commands was called, with a message that names `file.save` (kept as it is), so the
//! schema lists the words and the handler stays the one to refuse them. Whether the path can be
//! written, or the design has moved or changed on disk, stays the handler's to check.
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use super::check::whole;
use super::motion::require;
use super::types::not_blank_pattern;
/// `file.save_as`' `path` as `path_arg` reads it: text with something in it, under 4096 bytes;
/// anything else, `null` included, is refused with the handler's message.
fn save_as_path<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
match Value::deserialize(d)? {
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(Some(s)),
_ => Err(D::Error::custom(whole("`path` is required"))),
}
}
fn save_as_path_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "string", "pattern": not_blank_pattern(), "maxLength": 4095})
}
fn path_required(schema: &mut Schema) {
require(schema, "path");
}
fn format_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "string", "enum": ["container", "json"]})
}
fn compression_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"type": "string", "enum": ["fast", "compact"]})
}
/// Write the design to a file and make that file the design's own: the next `file.save` writes
/// it again. With no `path`, the design's current file is written; a design that has none yet
/// needs a `path`. A name that opens as another kind of file (`.step`, `.stl`, `.dxf` and the
/// like) is refused: `file.export` writes those. Not an undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SaveParams {
/// The design file to write. Default: the design's current file. A blank value counts as
/// left out.
#[serde(default)]
pub path: Option<String>,
/// container (`.sc`) or json. Default: json for a path ending in `.json`, otherwise
/// container. The handler refuses any other word.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "format_schema")]
pub format: Option<String>,
/// fast or compact. Default: fast. The handler refuses any other word.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "compression_schema")]
pub compression: Option<String>,
/// Keep the evaluated model in the file, so the design opens without rebuilding. Default:
/// true. Plain json has no room for it.
#[serde(default)]
pub cache: Option<bool>,
/// Keep the display meshes in the file. Default: false.
#[serde(default)]
pub preview: Option<bool>,
/// Replace the design's file even though another program or computer changed it since it was
/// opened or saved; without it that save is refused. Only matters when the path is the
/// design's own file. Default: false.
#[serde(default)]
pub overwrite: Option<bool>,
}
/// Write the design to a new file (a design file name, `.sc` as a rule) and make that file the
/// design's own. A name that opens as another kind of file (`.step`, `.stl`, `.dxf` and the like)
/// is refused: `file.export` writes those. A design with no name of its own, or one borrowed from
/// the file it was saved as before, takes the new file's name. Not an undo step.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = path_required)]
pub struct SaveAsParams {
// Optional in the type and required in the schema (`path_required`).
/// The design file to write: text, not blank, under 4096 bytes.
#[serde(default, deserialize_with = "save_as_path", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "save_as_path_schema")]
pub path: Option<String>,
/// container (`.sc`) or json. Default: json for a path ending in `.json`, otherwise
/// container. The handler refuses any other word.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "format_schema")]
pub format: Option<String>,
/// fast or compact. Default: fast. The handler refuses any other word.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "compression_schema")]
pub compression: Option<String>,
/// Keep the evaluated model in the file, so the design opens without rebuilding. Default:
/// true. Plain json has no room for it.
#[serde(default)]
pub cache: Option<bool>,
/// Keep the display meshes in the file. Default: false.
#[serde(default)]
pub preview: Option<bool>,
/// As for `file.save`, when `path` is the design's own file: replace it even though another
/// program or computer changed it. Default: false.
#[serde(default)]
pub overwrite: Option<bool>,
}
/// What `file.save` and `file.save_as` return.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SaveResult {
/// The file written.
pub path: String,
/// Its size in bytes.
pub bytes: usize,
/// The design's name once saved (a design with no name of its own takes the file's).
pub name: String,
/// A file a newer SolidCraft saved: the format number it keeps.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kept_format: Option<String>,
/// A newer file: what could not be kept, in sentences, when something could not.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warnings: Option<Vec<String>>,
/// A newer file: what could not be kept, one item each, when something could not.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not_kept: Option<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>,
}
#[cfg(test)]
#[path = "save_tests.rs"]
mod tests;
+219
View File
@@ -0,0 +1,219 @@
use std::path::PathBuf;
use serde_json::{Value, json};
use super::{SaveAsParams, SaveParams, SaveResult};
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};
#[test]
fn schemas_and_the_key_check_agree() {
let keys = ["cache", "compression", "format", "overwrite", "path", "preview"];
assert_keys(input_schema_for::<SaveParams>(), &keys);
assert_keys(input_schema_for::<SaveAsParams>(), &keys);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("file.save", json!({})),
("file.save", json!({"path": ""})),
("file.save", json!({"path": " "})),
("file.save", json!({"path": null})),
("file.save_as", json!({})),
("file.save_as", json!({"path": ""})),
("file.save_as", json!({"path": " "})),
("file.save_as", json!({"path": 5})),
("file.save_as", json!({"path": null})),
("file.save_as", json!({"path": ["a.sc"]})),
("file.save_as", json!({"path": "x".repeat(4096)})),
// Words the handler refuses itself, in its own wording (which names `file.save`).
("file.save_as", json!({"path": "/tmp/solidcraft-save-schema-never.sc", "format": "xml"})),
("file.save_as", json!({"path": "/tmp/solidcraft-save-schema-never.sc", "compression": "max"})),
("file.save", json!({"path": "/tmp/solidcraft-save-schema-never.sc", "format": "xml"})),
("file.save", json!({"path": "/tmp/solidcraft-save-schema-never.sc", "compression": "max"})),
// A name that opens as another kind of file.
("file.save_as", json!({"path": "/tmp/solidcraft-save-schema-never.step"})),
("file.save", json!({"path": "/tmp/solidcraft-save-schema-never.stl"})),
]);
}
fn parse<T: serde::de::DeserializeOwned>(v: Value) -> Result<T, String> {
serde_path_to_error::deserialize::<_, T>(&v).map_err(|e| e.to_string())
}
/// The types read each key as the handlers do: `null` is absent, `file.save` takes any text for
/// `path` (blank means the current file) and `file.save_as` takes not-blank text under 4096
/// bytes, with the handler's message for the rest.
#[test]
fn the_types_read_each_key_as_the_handlers_do() {
let all = json!({"format": "json", "compression": "compact", "cache": false, "preview": true, "overwrite": true});
for key in ["path", "format", "compression", "cache", "preview", "overwrite"] {
assert!(parse::<SaveParams>(json!({ key: null })).is_ok(), "{key}: null reads as left out");
}
assert!(parse::<SaveParams>(json!({})).is_ok());
assert!(parse::<SaveParams>(json!({"path": ""})).is_ok());
assert!(parse::<SaveParams>(json!({"path": "a.sc"})).is_ok());
let mut with_path = all.clone();
with_path["path"] = json!("a.sc");
assert!(parse::<SaveParams>(with_path.clone()).is_ok());
assert!(parse::<SaveAsParams>(with_path).is_ok());
assert!(parse::<SaveParams>(json!({"path": 5})).is_err());
assert!(parse::<SaveParams>(json!({"cache": "yes"})).is_err());
assert!(parse::<SaveAsParams>(json!({})).is_ok(), "a missing path is the handler's to refuse");
assert!(parse::<SaveAsParams>(json!({"path": "x".repeat(4095)})).is_ok());
for bad in [json!(""), json!(" \t"), json!(7), json!(null), json!(["a"]), json!("x".repeat(4096))] {
assert_eq!(parse::<SaveAsParams>(json!({"path": bad})).err().as_deref().map(|e| e.ends_with("`path` is required")), Some(true), "{bad}");
}
// The words the handler checks are left to it, in both commands.
assert!(parse::<SaveAsParams>(json!({"path": "a.sc", "format": "xml", "compression": "max"})).is_ok());
}
#[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::<SaveParams>(),
&[
(json!({}), true),
(json!({"path": "a.sc", "format": "container", "compression": "fast", "cache": true, "preview": false, "overwrite": false}), true),
(json!({"format": "json", "compression": "compact"}), true),
(json!({"format": "xml"}), false),
(json!({"compression": "max"}), false),
(json!({"cache": 1}), false),
(json!({"path": 1}), false),
],
);
takes(
input_schema_for::<SaveAsParams>(),
&[
(json!({"path": "a.sc"}), true),
(json!({"path": "a.json", "format": "json", "compression": "compact", "cache": false, "preview": true, "overwrite": true}), true),
(json!({}), false),
(json!({"path": "a.sc", "format": "xml"}), false),
],
);
}
/// The `path` pattern is the not-blank one (`conforms` doesn't read patterns), with the length cap.
#[test]
fn save_as_path_schema_is_pinned() {
let schema = input_schema_for::<SaveAsParams>();
let path = &schema.as_value()["properties"]["path"];
assert_eq!(path["pattern"], json!(crate::schema::types::not_blank_pattern()));
assert_eq!(path["maxLength"], json!(4095));
assert_eq!(schema.as_value()["required"], json!(["path"]));
assert!(input_schema_for::<SaveParams>().as_value().get("required").is_none());
}
/// A misspelt key is reported by the check, with the key it probably meant.
#[test]
fn the_check_names_what_is_wrong() {
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert_eq!(issues("file.save", json!({"overwite": true})), ["unknown key `overwite`; did you mean `overwrite`?"]);
assert_eq!(issues("file.save_as", json!({"path": "a.sc", "cache": "no"})), ["`cache` must be true or false"]);
assert_eq!(issues("file.save_as", json!({"path": " "})), ["`path` is required"]);
assert_eq!(issues("file.save", json!({"path": "a.sc", "format": "container"})), Vec::<String>::new());
}
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-save-schema-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
dir
}
fn matches(id: &str, reply: &Value) {
let schema = output_schema_for::<SaveResult>();
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 both commands, in Warn: no `param_warnings`, and each reply matches the output
/// schema and is what the handler returned before (the keys a plain save has, nothing else).
#[test]
fn save_replies_match_the_output_schema() {
let dir = folder("replies");
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}"));
run("solid.box", json!({"length": 10, "width": 20, "height": 5}));
let design = dir.join("part.sc");
let first = run("file.save_as", json!({"path": design}));
assert_eq!(first["path"], json!(design), "{first}");
assert!(first["bytes"].as_u64().is_some_and(|b| b > 0) && first["name"] == "part", "{first}");
let mut keys: Vec<_> = first.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
keys.sort();
assert_eq!(keys, ["bytes", "name", "path"], "{first}");
matches("file.save_as", &first);
matches("file.save", &run("file.save", json!({})));
matches("file.save", &run("file.save", json!({"path": null, "format": null})));
matches("file.save", &run("file.save", json!({"path": ""})));
matches("file.save", &run("file.save", json!({"path": design, "compression": "compact", "cache": false, "preview": true, "overwrite": true})));
matches("file.save", &run("file.save", json!({"path": design, "format": "container", "compression": "fast"})));
let json_file = dir.join("part.json");
matches("file.save_as", &run("file.save_as", json!({"path": json_file})));
matches("file.save_as", &run("file.save_as", json!({"path": dir.join("other.sc"), "format": "json", "overwrite": false})));
matches("file.save", &run("file.save", json!({"path": dir.join("again.sc"), "preview": false, "cache": true})));
// A misspelt key still runs in Warn, says so, and the reply still matches the schema.
let warned = run("file.save", json!({"path": design, "overwite": true}));
let warnings = warned["param_warnings"].as_array().cloned().unwrap_or_default();
assert_eq!(warnings.len(), 1, "{warned}");
assert!(warnings[0].as_str().is_some_and(|w| w.contains("overwite") && w.contains("overwrite")), "{warned}");
let schema = output_schema_for::<SaveResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &warned), "{warned}");
let warned = run("file.save_as", json!({"path": design, "compresion": "fast"}));
assert!(warned["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{warned}");
// In Strict the same misspelling is refused.
let mut strict = Session { param_check: ParamCheck::Strict, ..Session::default() };
assert!(strict.execute("file.save_as", &json!({"path": design, "overwite": true})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// A file a newer SolidCraft saved keeps its format number: the reply carries it and matches. What
/// could not be kept (`warnings`, `not_kept`) is part of the type, and serializes as the handler
/// has always written it.
#[test]
fn a_newer_files_reply_matches_the_output_schema() {
let dir = folder("newer");
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 5}));
let plain = dir.join("plain.json");
run(&mut s, "file.save_as", json!({"path": plain}));
let mut file: Value = serde_json::from_str(&std::fs::read_to_string(&plain).unwrap_or_default()).unwrap_or_default();
file["format"] = json!(solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT + 1));
file["future_section"] = json!({"anything": [1, 2, 3]});
file["features"][0]["draft_v2"] = json!({"angle": 3.0});
let newer = dir.join("newer.json");
std::fs::write(&newer, file.to_string()).unwrap_or_else(|e| panic!("{e}"));
run(&mut s, "doc.open", json!({"path": newer}));
let saved = run(&mut s, "file.save", json!({}));
assert_eq!(saved["kept_format"], file["format"], "{saved}");
assert!(saved.get("warnings").is_none() && saved.get("not_kept").is_none(), "{saved}");
matches("file.save", &saved);
let lost = serde_json::to_value(SaveResult {
path: "a.sc".into(),
bytes: 3,
name: "a".into(),
kept_format: Some("solidcraft/99".into()),
warnings: Some(vec!["Not kept: x. The previous file is `a.sc.bak`.".into()]),
not_kept: Some(vec!["x".into()]),
param_warnings: Vec::new(),
})
.unwrap_or_default();
assert_eq!(
lost,
json!({"path": "a.sc", "bytes": 3, "name": "a", "kept_format": "solidcraft/99", "warnings": ["Not kept: x. The previous file is `a.sc.bak`."], "not_kept": ["x"]})
);
matches("file.save", &lost);
let _ = std::fs::remove_dir_all(&dir);
}
+8
View File
@@ -375,6 +375,14 @@ pub enum TangentMade {
constraints: Vec<String>,
/// The sketch's solve: `{solved, dof}`.
sketch: Value,
/// `true` when they already held and other constraints already determined them: they are
/// kept, shown as redundant (not when `if_redundant` is `refuse`, which refuses the call
/// instead). Absent otherwise.
#[serde(skip_serializing_if = "Option::is_none")]
redundant: Option<bool>,
/// With `redundant`: the constraints that determine the Tangent, as "id (Name)".
#[serde(skip_serializing_if = "Option::is_none")]
determined_by: Option<Vec<String>>,
},
}
@@ -0,0 +1,297 @@
//! Contract and result type for the command in `cmd/sketch.rs`: `sketch.dimension`.
//!
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler reads
//! it and fails with the handler's message. What only the design can tell (a sketch, curve or point
//! that exists, a model edge that projects to something, a name that is free, whether the
//! combination of entities can be dimensioned) stays the handler's to check. `entities` is
//! optional in the type, so the handler keeps its own message when it is left out, and required in
//! the schema.
//!
//! The handler reads some keys only in some cases, and so does the contract: a driven dimension
//! (`driven: true`) measures and has no value, so its `value` (apart from a definition, which
//! the handler refuses by itself), `scale_sketch` and `if_redundant` are not read and not checked.
//! `if_redundant` is read only once the dimension turns out to be determined by the sketch.
//!
//! The handler reads a few keys more leniently than a type can (a `type` that is not text counts
//! as `auto`, a `driven` that is not true or false as false): the type refuses them, which is what
//! warn reports and strict refuses. 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 in its own order).
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};
use super::check::whole;
use super::motion::require;
use super::sketch_ends::SketchTarget;
use super::types::{LengthExpr, Point2, Point3};
use crate::cmd::{MAX_PROJECTED_ENTITIES, model_ref};
use crate::params::json_brief;
/// One or two entities to dimension: a curve or point id (`l1`, `p3`, `l1.start`), `x` or `y` (a
/// sketch axis), `mid:<line id>` (a line's midpoint), or a model edge `{edge: [x, y, z], body?}`
/// or vertex `{vertex: [x, y, z], body?}`, which the handler projects into the sketch first. One
/// id may be given as text rather than a list.
#[derive(Clone, Debug, PartialEq)]
pub struct DimensionEntities(pub Vec<Value>);
impl DimensionEntities {
/// The entities `v` lists, or the handler's message. In the handler's order: a list of more
/// than 1000 is refused before anything, then the entries that are not names, then the count.
/// A model reference is left to the handler: it looks up the sketch before it reads the form
/// of the reference, so a reference that is no form of one is its to refuse (with "no sketch
/// is being edited" when there is no sketch, which only the session can tell).
fn read(v: &Value) -> Result<DimensionEntities, String> {
let list: Vec<Value> = match v {
Value::String(_) => vec![v.clone()],
Value::Array(list) => {
if list.len() > MAX_PROJECTED_ENTITIES {
return Err(format!("`entities` may list at most {MAX_PROJECTED_ENTITIES} entities"));
}
if let Some((i, e)) = list.iter().enumerate().find(|(_, e)| !e.is_string() && !model_ref(e)) {
return Err(format!("`entities` must list names; the entry {} is {}", i + 1, json_brief(e)));
}
list.clone()
}
other => return Err(format!("`entities` must be a list of names; it is {}", json_brief(other))),
};
if list.is_empty() || list.len() > 2 {
return Err("`entities` must list one or two sketch entities".into());
}
Ok(DimensionEntities(list))
}
}
impl JsonSchema for DimensionEntities {
fn schema_name() -> Cow<'static, str> {
"DimensionEntities".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
let model = |key: &str| {
serde_json::json!({
"type": "object",
"properties": {key: point, "body": {"type": "string", "description": "The body's name. Default: any body. Text only; anything else is read as left out."}},
"required": [key]
})
};
let one = serde_json::json!({"anyOf": [{"type": "string"}, model("edge"), model("vertex")]});
json_schema!({
"description": "One or two entities: a curve or point id (`l1`, `p3`, `l1.start`, `c1.center`), `x` or `y` (a sketch axis), `mid:<line id>` (a line's midpoint), or a model edge `{edge: [x, y, z], body?}` or vertex `{vertex: [x, y, z], body?}`, projected into the sketch first and measured there. One id may be given as text instead of a list.",
"anyOf": [{"type": "string"}, {"type": "array", "items": one, "minItems": 1, "maxItems": 2}]
})
}
}
/// Dimension one or two sketch entities: a length, distance, radius, diameter, angle or ellipse
/// half-axis, driving the sketch (it adds a parameter) or only measuring (`driven: true`). The
/// kind follows from the entities, `type` and where `text_at` is. One undo step.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = entities_required)]
pub struct SketchDimensionParams {
// Optional in the type and required in the schema (`entities_required`): left out, the
// handler refuses it with its own message, not the check's "is required".
/// The entities to dimension, one or two.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "DimensionEntities")]
pub entities: Option<DimensionEntities>,
/// The sketch (default: the one being edited).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "SketchTarget")]
pub sketch: Option<SketchTarget>,
/// What to measure, in any case: auto (the default: from the entities and where `text_at`
/// is), distance, horizontal (or distance_x), vertical (or distance_y), length, radius,
/// diameter, major or minor (an ellipse's half-axis), angle, sweep (an arc's angle),
/// arc_length (arclength), overall_length (a slot's overall length), offset. Other text is no
/// known type: the entities' default measure (a length or a radius), with no choice by
/// `text_at`.
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub kind: Option<String>,
/// With type auto or distance: `horizontal` or `vertical` measures along that axis, as
/// type does. Any other text is ignored.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub orientation: Option<String>,
/// The dimension's value: a number (in the design's units, or degrees for an angle) or an
/// expression such as `"width / 2"` or `"50 mm"`; or `name = expression` (also `#name =
/// expression`), which adds the user parameter `name` and dimensions with it (refused when the
/// name is taken or reserved; the reply's `defined` names it). Default: what the geometry
/// measures now. Not read for a driven dimension, which has no value (a definition is refused
/// there).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "LengthExpr")]
pub value: Option<LengthExpr>,
/// Where the dimension's text goes, in sketch coordinates [x, y] (mm): with type auto it also
/// chooses what is measured (horizontal or vertical distance, an angle's sector, an ellipse's
/// half-axis).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Point2")]
pub text_at: Option<Point2>,
/// True to place a driven (reference) dimension: it measures, has no parameter and does not
/// constrain. Default: false.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub driven: Option<bool>,
/// When this is the sketch's first length dimension and nothing in the sketch fixes a size
/// (no other driving length dimension, nothing fixed or projected), scale the whole sketch
/// about its origin to the value instead of distorting it (the reply's `scaled` is the factor).
/// Default: false. Not read for a driven dimension.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub scale_sketch: Option<bool>,
/// For an ellipse with type auto: `major` or `minor`, the half-axis an earlier placement
/// chose, kept while `text_at` is near the boundary between them (any case; other text is
/// ignored).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub previous_axis: Option<String>,
/// For an ellipse with type auto: true gives the half-axis `text_at` picks, false the minor
/// radius wherever `text_at` is. Default: true.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "bool")]
pub half_axes: Option<bool>,
/// `driven` (any case): when the sketch already determines the dimension, place it as a
/// driven (reference) dimension instead of refusing it; the reply then has `redundant` and
/// `determined_by`. Any other text refuses it. Read only when the dimension turns out to be
/// determined, and not for a driven dimension.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "String")]
pub if_redundant: Option<String>,
}
fn entities_required(schema: &mut Schema) {
require(schema, "entities");
}
/// `null` is a key left out, as the handler's readers read it.
fn given<'a>(o: &'a Map<String, Value>, key: &str) -> Option<&'a Value> {
o.get(key).filter(|v| !v.is_null())
}
/// A key's text, or its message.
fn text(o: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
match given(o, key) {
None => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(format!("`{key}` must be text")),
}
}
/// A key's true or false, or its message (`message`, when the handler has its own).
fn flag(o: &Map<String, Value>, key: &str, message: Option<&str>) -> Result<Option<bool>, String> {
match given(o, key) {
None => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(_) => Err(message.map_or_else(|| format!("`{key}` must be true or false"), str::to_string)),
}
}
/// A key read by a type's own `Deserialize`, which says what is wrong ("must be …").
fn typed<'a, T: Deserialize<'a>>(o: &'a Map<String, Value>, key: &str) -> Result<Option<T>, String> {
match given(o, key) {
None => Ok(None),
Some(v) => T::deserialize(v).map(Some).map_err(|e| format!("`{key}` {e}")),
}
}
impl SketchDimensionParams {
/// The keys the handler reads, in its order and in the cases it reads them.
fn read(o: &Map<String, Value>) -> Result<SketchDimensionParams, String> {
let entities = given(o, "entities").map(DimensionEntities::read).transpose()?;
// `entities: null` and a missing one are both "must list one or two sketch entities".
let kind = text(o, "type")?;
let orientation = text(o, "orientation")?;
let text_at = typed::<Point2>(o, "text_at")?;
let driven = flag(o, "driven", None)?;
let previous_axis = text(o, "previous_axis")?;
let half_axes = flag(o, "half_axes", Some("`half_axes` must be true or false"))?;
// The sketch is read after the entities; a model reference among them is left to the
// handler, which looks the sketch up first (see `DimensionEntities::read`).
let sketch = typed::<SketchTarget>(o, "sketch")?;
let (mut value, mut scale_sketch, mut if_redundant) = (None, None, None);
// A driven dimension measures: it has no value to read, nothing to scale and nothing it
// could be redundant with.
if driven != Some(true) {
value = typed::<LengthExpr>(o, "value")?;
scale_sketch = flag(o, "scale_sketch", None)?;
if_redundant = text(o, "if_redundant")?;
}
Ok(SketchDimensionParams {
entities,
sketch,
kind,
orientation,
value,
text_at,
driven,
scale_sketch,
previous_axis,
half_axes,
if_redundant,
})
}
}
impl<'de> Deserialize<'de> for SketchDimensionParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let o = Map::<String, Value>::deserialize(d)?;
SketchDimensionParams::read(&o).map_err(|why| D::Error::custom(whole(why)))
}
}
/// What `sketch.dimension` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DimensionResult {
/// The parameter that holds the dimension's value (`d1`, or the name a `name = expression`
/// value defined); null for a driven dimension, which has none.
pub param: Option<String>,
/// `true` for a driven (reference) dimension, as asked or placed instead of a redundant one.
/// Absent for a driving one.
#[serde(skip_serializing_if = "Option::is_none")]
pub driven: Option<bool>,
/// What the dimension is: `LinearDimension`, `RadialDimension`, `DiameterDimension`,
/// `AngularDimension`, `ArcLengthDimension` or `LinearDiameterDimension`.
#[serde(rename = "type")]
pub kind: String,
/// The parameter's expression, as the design stores it, in the design's units (a driving
/// dimension only).
#[serde(skip_serializing_if = "Option::is_none")]
pub expression: Option<String>,
/// What the dimension measures on the solved sketch, in `unit`.
pub measured: f64,
/// `mm` for a length or `deg` for an angle.
pub unit: String,
/// The sketch's solve: `{solved, dof}`.
pub sketch: Value,
/// The factor the whole sketch was scaled by (`scale_sketch`), when it was.
#[serde(skip_serializing_if = "Option::is_none")]
pub scaled: Option<f64>,
/// For an ellipse: the half-axis the dimension measures, `major` or `minor`.
#[serde(skip_serializing_if = "Option::is_none")]
pub axis: Option<String>,
/// The user parameter a `name = expression` value added.
#[serde(skip_serializing_if = "Option::is_none")]
pub defined: Option<String>,
/// `true` when the sketch already determined the dimension and `if_redundant` was `driven`:
/// it was placed as a driven dimension.
#[serde(skip_serializing_if = "Option::is_none")]
pub redundant: Option<bool>,
/// With `redundant`: the constraints that determine it, as "id (Name)".
#[serde(skip_serializing_if = "Option::is_none")]
pub determined_by: Option<Vec<String>>,
/// What the command added to its sketch, `{points, curves, constraints, dimensions}` (lists
/// of ids): the session adds it to the reply of a sketch command, so the handler leaves it out.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Value>,
/// 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_dimension_tests.rs"]
mod tests;
@@ -0,0 +1,352 @@
use serde_json::{Value, json};
use super::{DimensionResult, SketchDimensionParams};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<SketchDimensionParams>(),
&["driven", "entities", "half_axes", "if_redundant", "orientation", "previous_axis", "scale_sketch", "sketch", "text_at", "type", "value"],
);
// Only `entities` is required, and the type leaves it to the handler's message.
let schema = input_schema_for::<SketchDimensionParams>();
assert_eq!(schema.as_value()["required"], json!(["entities"]));
assert!(serde_json::from_value::<SketchDimensionParams>(json!({})).is_ok());
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("sketch.dimension", json!({})),
("sketch.dimension", json!({"entities": null})),
("sketch.dimension", json!({"entities": []})),
("sketch.dimension", json!({"entities": ["l1", "l2", "l3"]})),
("sketch.dimension", json!({"entities": 5})),
("sketch.dimension", json!({"entities": true})),
("sketch.dimension", json!({"entities": {"edge": [0, 0, 0]}})),
("sketch.dimension", json!({"entities": [1]})),
("sketch.dimension", json!({"entities": ["l1", null]})),
("sketch.dimension", json!({"entities": [{"foo": 1}]})),
("sketch.dimension", json!({"entities": [["l1"]]})),
("sketch.dimension", json!({"entities": vec!["l1"; 1001]})),
("sketch.dimension", json!({"entities": [{"edge": 5}]})),
("sketch.dimension", json!({"entities": [{"vertex": [1, 2]}]})),
// The handler looks the sketch up before it reads a model reference's form.
("sketch.dimension", json!({"entities": [{"edge": 5}], "sketch": true})),
("sketch.dimension", json!({"entities": ["l1"], "half_axes": "no"})),
("sketch.dimension", json!({"entities": ["l1"], "half_axes": 1})),
("sketch.dimension", json!({"entities": ["l1"], "half_axes": []})),
("sketch.dimension", json!({"entities": ["l1"], "sketch": true})),
("sketch.dimension", json!({"entities": ["l1"], "sketch": -1})),
("sketch.dimension", json!({"entities": ["l1"], "sketch": 2.5})),
("sketch.dimension", json!({"entities": ["l1"], "sketch": [1]})),
]);
}
/// A session that is editing a sketch "Plan" with two lines, a circle and two more lines (l3, l4).
fn editing(level: ParamCheck) -> Session {
let mut s = Session { param_check: level, ..Session::default() };
for (id, p) in [
("sketch.create", json!({"plane": "XY", "name": "Plan"})),
("sketch.line", json!({"points": [[0, 0], [10, 0.5]]})),
("sketch.line", json!({"points": [[0, 20], [10, 30]]})),
("sketch.circle.center", json!({"center": [50, 0], "radius": 5})),
("sketch.line", json!({"points": [[0, 40], [10, 40]]})),
("sketch.line", json!({"points": [[0, 40], [5, 50]]})),
] {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
}
s
}
/// The same refusals in a sketch being edited, where the handler goes on to read the sketch and
/// its entities, and the ones the design settles: strict says what the handler says.
#[test]
fn strict_keeps_todays_errors_in_a_sketch() {
let cases = [
json!({}),
json!({"entities": null}),
json!({"entities": []}),
json!({"entities": ["l1", "l2", "c1"]}),
json!({"entities": 5}),
json!({"entities": [1]}),
json!({"entities": ["l1", {"x": 1}]}),
json!({"entities": vec!["l1"; 1001]}),
json!({"entities": [{"edge": [1, 2]}]}),
json!({"entities": [{"edge": 5}], "sketch": "Plan"}),
json!({"entities": ["l1"], "half_axes": "no"}),
json!({"entities": ["l1"], "half_axes": 0, "sketch": true}),
json!({"entities": ["l1"], "sketch": true}),
json!({"entities": ["l1"], "sketch": -1}),
json!({"entities": ["l1"], "sketch": 1.5}),
// What the design settles: the contract passes these on.
json!({"entities": ["nope"]}),
json!({"entities": ["l1"], "sketch": "Missing"}),
json!({"entities": ["l1", "c1", "l2"]}),
json!({"entities": ["l1"], "value": "w ="}),
json!({"entities": ["l1"], "value": "w = 5", "driven": true}),
json!({"entities": ["l1"], "value": "w = 5 +"}),
json!({"entities": ["c1", "c1"]}),
];
for (index, p) in cases.iter().enumerate() {
let error = |level: ParamCheck| editing(level).execute("sketch.dimension", p).err().map(|e| e.to_string());
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{index} {p} should fail today");
assert_eq!(error(ParamCheck::Strict), off, "{index} {p}");
}
}
/// What strict refuses on purpose: a value of the wrong type that the handler reads as if the key
/// were left out. Warn names each one and the call still runs.
#[test]
fn strict_refuses_what_the_handler_ignores() {
let cases = [
(json!({"entities": ["l1"], "type": 5}), "`type` must be text"),
(json!({"entities": ["l1"], "orientation": true}), "`orientation` must be text"),
(json!({"entities": ["l1"], "value": true}), "`value` must be a number or an expression"),
(json!({"entities": ["l1"], "value": ""}), "`value` must be a number or an expression"),
(json!({"entities": ["l1"], "value": 1e12}), "`value` must be a number between -1e9 and 1e9"),
(json!({"entities": ["l1"], "text_at": [1]}), "`text_at` must be a point [x, y] of numbers"),
(json!({"entities": ["l1"], "driven": "yes"}), "`driven` must be true or false"),
(json!({"entities": ["l1"], "scale_sketch": 1}), "`scale_sketch` must be true or false"),
(json!({"entities": ["l1"], "previous_axis": 1}), "`previous_axis` must be text"),
(json!({"entities": ["l1"], "if_redundant": 5}), "`if_redundant` must be text"),
(json!({"entities": ["l1"], "drivn": true}), "unknown key `drivn`; did you mean `driven`?"),
];
for (p, message) in cases {
let found = issues("sketch.dimension", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
assert_eq!(found, [message], "{p}");
let strict = editing(ParamCheck::Strict).execute("sketch.dimension", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!(strict.contains(message), "{p}: {strict}");
let off = editing(ParamCheck::Off).execute("sketch.dimension", &p);
assert!(off.is_ok(), "{p} works with the check off: {off:?}");
}
}
fn parses(v: Value) -> bool {
serde_json::from_value::<SketchDimensionParams>(v).is_ok()
}
/// A driven dimension has no value, no scale and nothing it could be redundant with: those keys
/// are not read for it (the handler never looks at them), and are for a driving one. The other
/// keys are read either way.
#[test]
fn a_driven_dimension_reads_only_what_the_handler_reads() {
for key in [("value", json!(true)), ("scale_sketch", json!("yes")), ("if_redundant", json!(7)), ("value", json!(""))] {
let (k, v) = key;
let driving = json!({"entities": ["l1"], k: v.clone()});
let driven = json!({"entities": ["l1"], "driven": true, k: v.clone()});
assert!(!parses(driving), "{k} {v} is read for a driving dimension");
assert!(parses(driven), "{k} {v} is not read for a driven one");
assert!(issues("sketch.dimension", &json!({"entities": ["l1"], "driven": true, k: v})).is_empty());
}
// `driven: false` and `null` are driving.
for driven in [json!(false), json!(null)] {
assert!(!parses(json!({"entities": ["l1"], "driven": driven, "value": true})));
}
// Read in both: the type, where the text goes, the ellipse keys and the sketch.
for key in [
("type", json!(1)),
("orientation", json!(1)),
("text_at", json!("x")),
("previous_axis", json!([])),
("half_axes", json!(1)),
("sketch", json!(true)),
] {
let (k, v) = key;
assert!(!parses(json!({"entities": ["l1"], "driven": true, k: v.clone()})), "{k} {v} when driven");
assert!(!parses(json!({"entities": ["l1"], k: v.clone()})), "{k} {v}");
}
// The handler's own refusal of a definition on a driven dimension is left to it.
assert!(parses(json!({"entities": ["l1"], "driven": true, "value": "w = 5"})));
}
/// Each key reads as the handler reads it: `null` is left out, entities are names or model
/// references, a value is a number or an expression, a sketch an id or a name.
#[test]
fn the_types_read_each_key_like_the_handler() {
assert!(parses(json!({"entities": null, "sketch": null, "type": null, "orientation": null, "value": null, "text_at": null})));
assert!(parses(json!({"driven": null, "scale_sketch": null, "previous_axis": null, "half_axes": null, "if_redundant": null})));
for entities in [
json!("l1"),
json!(["l1"]),
json!(["l1", "p2"]),
json!(["l1.start", "mid:l2"]),
json!(["x", "c1.center"]),
json!(["p1", {"edge": [1, 2, 3], "body": "Flange"}]),
json!([{"vertex": [1, 2, 3]}, {"edge": {"x": 1, "y": 2, "z": 3}}]),
// Forms the handler passes on to the design: a body, a sketch or a plane in place of a point.
json!([{"edge": 5, "body": "B"}]),
] {
assert!(parses(json!({"entities": entities})), "{entities}");
}
for entities in [json!(5), json!([5]), json!([null]), json!([{"face": [1, 2, 3]}]), json!([{}]), json!({"edge": [1, 2, 3]}), json!([["a"]])] {
assert!(!parses(json!({"entities": entities})), "{entities}");
}
// The count is the handler's: one or two.
assert!(!parses(json!({"entities": []})) && !parses(json!({"entities": ["a", "b", "c"]})));
// A value: a number, or an expression of up to 4096 characters, including `name = expression`.
for value in [json!(2), json!(-1.5), json!("width / 2"), json!("50 mm"), json!("w = 40"), json!("#w = 4 mm")] {
assert!(parses(json!({"entities": ["l1"], "value": value})), "{value}");
}
for value in [json!(true), json!([1]), json!({}), json!(""), json!(" "), json!("x".repeat(4097))] {
assert!(!parses(json!({"entities": ["l1"], "value": value})), "{value}");
}
// Points as `params::vec2` reads them.
assert!(parses(json!({"entities": ["l1"], "text_at": [1, 2]})) && parses(json!({"entities": ["l1"], "text_at": {"x": 1, "y": 2}})));
assert!(!parses(json!({"entities": ["l1"], "text_at": [1, 2, 3]})) && !parses(json!({"entities": ["l1"], "text_at": [1, "2"]})));
// The type is text of any content: an unknown word is never refused (the handler measures the
// entities' default for it).
assert!(parses(json!({"entities": ["l1"], "type": "ARC_LENGTH", "orientation": "Horizontal", "previous_axis": "major"})));
assert!(parses(json!({"entities": ["l1"], "type": "no such type", "if_redundant": "refuse"})));
// A sketch: a whole number or text.
for sketch in [json!(3), json!("Plan"), json!("7")] {
assert!(parses(json!({"entities": ["l1"], "sketch": sketch})), "{sketch}");
}
for sketch in [json!(-1), json!(1.5), json!(false), json!([1])] {
assert!(!parses(json!({"entities": ["l1"], "sketch": sketch})), "{sketch}");
}
}
/// The schema says what the handler takes: `entities` is required, and a driven dimension may
/// carry the keys that only a driving one reads.
#[test]
fn the_schema_takes_what_the_handler_reads() {
let schema = input_schema_for::<SketchDimensionParams>();
let schema = schema.as_value();
for (p, takes) in [
(json!({"entities": ["l1"]}), true),
(json!({"entities": "l1", "type": "radius", "value": "r = 5", "text_at": [1, 2], "sketch": 3}), true),
(json!({"entities": ["a", {"edge": [1, 2, 3], "body": "B"}], "driven": true, "if_redundant": "driven", "scale_sketch": true}), true),
(json!({"entities": ["e"], "half_axes": false, "previous_axis": "minor", "orientation": "vertical"}), true),
(json!({}), false),
(json!({"value": 3}), false),
(json!({"entities": []}), false),
(json!({"entities": ["a", "b", "c"]}), false),
(json!({"entities": ["a"], "value": true}), false),
(json!({"entities": ["a"], "driven": 1}), false),
(json!({"entities": ["a"], "colour": 1}), false),
] {
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
}
}
fn matches(r: &Value) {
let schema = output_schema_for::<DimensionResult>();
let schema = schema.as_value();
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
assert!(r.get("param_warnings").is_none(), "{r}");
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
}
/// Each form of the command, run in Warn: the reply has no warning and matches its output schema.
#[test]
fn replies_match_the_output_schema() {
let mut s = editing(ParamCheck::Warn);
let dim = |s: &mut Session, p: Value| {
let r = run(s, "sketch.dimension", p);
matches(&r);
r
};
// A length, with a value, and a distance chosen by `type` and by `orientation`.
let r = dim(&mut s, json!({"entities": ["l1"], "value": 12}));
assert!(r["type"] == "LinearDimension" && r["unit"] == "mm" && r["expression"] == "12" && r.get("driven").is_none(), "{r}");
assert!(r["param"].is_string() && r["sketch"]["solved"] == true, "{r}");
let r = dim(&mut s, json!({"entities": ["l2"], "type": "horizontal", "value": "8 mm", "text_at": [5, 35]}));
assert!((r["measured"].as_f64().unwrap_or_default() - 8.0).abs() < 1e-6, "{r}");
dim(&mut s, json!({"entities": ["l3.start", "l3.end"], "orientation": "horizontal"}));
dim(&mut s, json!({"entities": ["l4.start", "l4.end"], "type": "vertical", "value": null, "sketch": "Plan"}));
// A circle's diameter, and a definition of a parameter by the value.
let r = dim(&mut s, json!({"entities": ["c1"], "value": "d = 12"}));
assert!(r["type"] == "DiameterDimension" && r["defined"] == "d" && r["expression"] == "d", "{r}");
// An angle between two lines, in degrees.
let r = dim(&mut s, json!({"entities": ["l3", "l4"], "type": "angle", "value": "45 deg"}));
assert!(r["type"] == "AngularDimension" && r["unit"] == "deg", "{r}");
// A driven dimension: no parameter, the keys only a driving one reads are not read.
let r = dim(&mut s, json!({"entities": ["l2"], "driven": true, "value": true, "scale_sketch": "yes", "if_redundant": 3, "type": "length"}));
assert!(r["param"].is_null() && r["driven"] == true && r.get("expression").is_none(), "{r}");
// One the sketch already determines, placed as driven instead.
let r = dim(&mut s, json!({"entities": ["l1.start", "l1.end"], "if_redundant": "driven"}));
assert!(r["redundant"] == true && r["driven"] == true && r["determined_by"].as_array().is_some_and(|a| !a.is_empty()), "{r}");
let r = s.execute("sketch.dimension", &json!({"entities": ["l1"], "value": 13}));
assert!(r.as_ref().is_err_and(|e| e.to_string().contains("over-constrain")), "{r:?}");
// The midpoints of two lines.
let mut s = editing(ParamCheck::Warn);
dim(&mut s, json!({"entities": ["mid:l1", "mid:l2"], "text_at": [30, 15], "type": "auto"}));
// Scaling a sketch's first dimension.
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}));
let r = dim(&mut s, json!({"entities": ["c1"], "value": "30 mm", "scale_sketch": true}));
assert!(r["scaled"].as_f64().is_some_and(|f| f > 1.0), "{r}");
// An ellipse's half-axis.
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let e = run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [20, 0], "minor_radius": 8}));
let e = e["curves"][0].as_str().unwrap_or_default().to_string();
let r = dim(&mut s, json!({"entities": [e], "text_at": [3, 16], "previous_axis": "Minor", "half_axes": true}));
assert!(r["axis"] == "major", "{r}");
let r = dim(&mut s, json!({"entities": [e], "driven": true, "half_axes": false}));
assert!(r["driven"] == true && r.get("axis").is_none(), "{r}");
}
/// A dimension that measures a model edge: the edge is projected first, and the call is read in
/// Warn without a warning.
#[test]
fn a_model_edge_is_an_entity() {
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base", "body_name": "Flange"}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Near"}));
let point = run(&mut s, "sketch.point", json!({"point": [50, 25]}))["point"].as_str().unwrap_or_default().to_string();
let r = run(&mut s, "sketch.dimension", json!({"entities": [point, {"edge": [50, 0, 0], "body": "Flange"}], "sketch": "Near"}));
matches(&r);
assert!((r["measured"].as_f64().unwrap_or_default() - 25.0).abs() < 1e-6, "{r}");
// A model reference that is no form of one is refused with the handler's words, in strict too.
let mut error = |level: ParamCheck| {
s.param_check = level;
s.execute("sketch.dimension", &json!({"entities": [point, {"edge": 5}]})).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.as_deref().is_some_and(|e| e.contains("cannot project")), "{off:?}");
assert_eq!(error(ParamCheck::Strict), off);
}
/// A misspelt key still runs in Warn, and says so, with a reply that still matches its schema.
#[test]
fn a_misspelt_key_is_reported_in_warn() {
let mut s = editing(ParamCheck::Warn);
let r = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "valeu": 12}));
let warnings = r["param_warnings"].as_array().cloned().unwrap_or_default();
assert_eq!(warnings, [json!("unknown key `valeu`")], "{r}");
let schema = output_schema_for::<DimensionResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
let mut s = editing(ParamCheck::Strict);
let e = s.execute("sketch.dimension", &json!({"entities": ["l1"], "text_t": [1, 1]})).err().map(|e| e.to_string());
assert!(e.is_some_and(|e| e.contains("unknown key `text_t`")));
}
/// The reply serializes as the handler's `json!` literal did: what is not there is left out.
#[test]
fn the_reply_leaves_out_what_is_absent() {
let mut s = editing(ParamCheck::Off);
let keys = |r: &Value| {
let mut k: Vec<&str> = r.as_object().map(|o| o.keys().map(String::as_str).collect()).unwrap_or_default();
k.sort_unstable();
k.join(",")
};
let driving = run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 12}));
assert_eq!(keys(&driving), "created,expression,measured,param,sketch,type,unit", "{driving}");
let driven = run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "driven": true}));
assert_eq!(keys(&driven), "created,driven,measured,param,sketch,type,unit", "{driven}");
let defined = run(&mut s, "sketch.dimension", json!({"entities": ["c1"], "value": "d = 12"}));
assert_eq!(keys(&defined), "created,defined,expression,measured,param,sketch,type,unit", "{defined}");
}
+89 -2
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), the rectangles and the polygons (#13196).
//! circles and the arcs (#12953), the rectangles and the polygons (#13196), and the two slots.
use std::borrow::Cow;
@@ -532,6 +532,93 @@ pub struct ArcCenterPointParams {
pub connect: Option<bool>,
}
/// A slot's `width`, as `slot_c2c` and `slot_overall` read it: a number above 1e-9 and below 1e9.
/// A value that is missing, not a number or too small is the handler's one message.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SlotWidth(pub f64);
impl<'de> Deserialize<'de> for SlotWidth {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
Value::deserialize(d)?
.as_f64()
.and_then(params::finite)
.filter(|w| *w > 1e-9)
.map(SlotWidth)
.ok_or_else(|| D::Error::custom(whole("`width` must be positive")))
}
}
impl JsonSchema for SlotWidth {
fn schema_name() -> Cow<'static, str> {
"SlotWidth".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The slot's width in mm: the distance between its two straight sides, and the diameter of its round ends. Positive.",
"type": "number", "exclusiveMinimum": 0, "exclusiveMaximum": 1e9
})
}
}
/// Marks `p0`, `p1` and `width` required in a slot's schema. The types hold them as optional
/// (`#[schemars(required)]` doesn't do this for a key with `serde(default)`), so that a call
/// that leaves one out reaches the handler, which refuses it with its own message.
fn slot_required(schema: &mut Schema) {
schema.insert("required".into(), json!(["p0", "p1", "width"]));
}
/// Draw a slot from the centres of its two round ends and its width.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = slot_required)]
pub struct SlotCenterToCenterParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// The centre of one round end, as [x, y] or {x, y} in mm. An expression, a point's id or a
/// snap is not taken.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "PlainPoint")]
pub p0: Option<PlainPoint>,
/// The centre of the other round end. Not the same point as `p0`.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "PlainPoint")]
pub p1: Option<PlainPoint>,
/// The width of the slot, in mm.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "SlotWidth")]
pub width: Option<SlotWidth>,
/// 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 slot from the two ends of its whole length and its width.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields, transform = slot_required)]
pub struct SlotOverallParams {
/// The sketch to draw in: its feature id or name. Default: the one being edited.
#[serde(default)]
pub sketch: Option<SketchTarget>,
/// The outer end of one round end, on the slot's axis, as [x, y] or {x, y} in mm. An
/// expression, a point's id or a snap is not taken.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "PlainPoint")]
pub p0: Option<PlainPoint>,
/// The outer end of the other round end. The distance from `p0` must be more than `width`.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "PlainPoint")]
pub p1: Option<PlainPoint>,
/// The width of the slot, in mm.
#[serde(default, deserialize_with = "present")]
#[schemars(with = "SlotWidth")]
pub width: Option<SlotWidth>,
/// 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>,
}
/// What a command added to its sketch: the ids that the sketch has now and did not before.
#[derive(Debug, Default, Serialize, JsonSchema)]
pub struct Created {
@@ -548,7 +635,7 @@ pub struct Created {
pub wires: Vec<String>,
}
/// What a drawing command returns for a line, a circle or an arc.
/// What a drawing command returns for a line, a circle, an arc or a slot.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DrawResult {
/// The ids of the curves drawn, in order.
@@ -3,7 +3,7 @@ use serde_json::{Value, json};
use super::{
ArcCenterPointParams, ArcThreePointParams, CircleCenterParams, CircleThreePointParams, CircleTwoPointParams, DrawResult, LineParams, PointParams,
PointResult, PolygonCircumscribedParams, PolygonEdgeParams, PolygonInscribedParams, PolygonResult, RectCenterParams, RectResult,
RectRoundedParams, RectThreePointParams, RectTwoPointParams, RoundedRectResult,
RectRoundedParams, RectThreePointParams, RectTwoPointParams, RoundedRectResult, SlotCenterToCenterParams, SlotOverallParams,
};
use crate::Session;
use crate::schema::tests::{assert_keys, conforms};
@@ -29,6 +29,8 @@ fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<CircleTwoPointParams>(), &["connect", "construction", "p0", "p1", "sketch"]);
assert_keys(input_schema_for::<CircleThreePointParams>(), &["connect", "construction", "p0", "p1", "p2", "sketch"]);
assert_keys(input_schema_for::<ArcThreePointParams>(), &["connect", "construction", "end", "id", "sketch", "start", "through"]);
assert_keys(input_schema_for::<SlotCenterToCenterParams>(), &["connect", "p0", "p1", "sketch", "width"]);
assert_keys(input_schema_for::<SlotOverallParams>(), &["connect", "p0", "p1", "sketch", "width"]);
assert_keys(
input_schema_for::<ArcCenterPointParams>(),
&["center", "clockwise", "connect", "construction", "end", "id", "sketch", "start", "sweep"],
@@ -114,6 +116,21 @@ fn strict_keeps_todays_errors() {
("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]})),
("sketch.slot.center_to_center", json!({"p1": [10, 0], "width": 4})),
("sketch.slot.center_to_center", json!({"p0": "l1.end", "p1": [10, 0], "width": 4})),
("sketch.slot.center_to_center", json!({"p0": ["1", 0], "p1": [10, 0], "width": 4})),
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5]})),
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": 0})),
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": -2})),
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": "4"})),
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": 1e12})),
("sketch.slot.center_to_center", json!({"p0": [3, 5], "p1": [3, 5], "width": 4})),
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [10, 5]})),
("sketch.slot.overall", json!({"p0": [0, 5], "p1": 7, "width": 4})),
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [10, 5], "width": true})),
("sketch.slot.overall", json!({"p0": [3, 5], "p1": [3, 5], "width": 4})),
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [4, 5], "width": 4})),
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [3, 5], "width": 4})),
];
for (id, p) in &cases {
let error = |level| session(level).execute(id, p).err().map(|e| e.to_string());
@@ -151,6 +168,10 @@ fn replies_match_the_output_schema_and_forms_are_accepted() {
("sketch.arc.center_point", json!({"center": [0, 50], "start": [5, 50], "end": [0, 55], "clockwise": true, "id": "arc_two"})),
("sketch.arc.center_point", json!({"center": [0, 60], "start": [5, 60], "sweep": 90})),
("sketch.arc.center_point", json!({"center": [0, 70], "start": [5, 70], "sweep": -45, "end": [0, 75]})),
("sketch.slot.center_to_center", json!({"p0": [0, 100], "p1": [20, 100], "width": 6})),
("sketch.slot.center_to_center", json!({"p0": {"x": 0, "y": 120}, "p1": {"x": 20, "y": 120}, "width": 0.5, "connect": false})),
("sketch.slot.overall", json!({"p0": [0, 140], "p1": [20, 140], "width": 6})),
("sketch.slot.overall", json!({"p0": [0, 160], "p1": [20, 170], "width": 6, "connect": true, "sketch": null})),
];
let point_calls = [
json!({"point": [30, 30]}),
@@ -192,6 +213,8 @@ fn the_check_does_not_change_the_reply() {
("sketch.circle.three_point", json!({"p0": [90, 0], "p1": [100, 10], "p2": [110, 0]})),
("sketch.arc.three_point", json!({"start": [0, 20], "end": [10, 20], "through": [5, 25]})),
("sketch.arc.center_point", json!({"center": [0, 40], "start": [5, 40], "sweep": 90})),
("sketch.slot.center_to_center", json!({"p0": [0, 100], "p1": [20, 100], "width": 6})),
("sketch.slot.overall", json!({"p0": [0, 140], "p1": [20, 140], "width": 6})),
];
let (mut off, mut strict) = (session(ParamCheck::Off), session(ParamCheck::Strict));
for (id, p) in &calls {
@@ -219,6 +242,8 @@ fn wrong_typed_keys_are_reported() {
("sketch.circle.three_point", json!({"p0": [0, 0], "p1": [5, 5], "p2": [10, 0]})),
("sketch.arc.three_point", json!({"start": [0, 0], "end": [10, 0], "through": [5, 5]})),
("sketch.arc.center_point", json!({"center": [0, 0], "start": [5, 0], "sweep": 90})),
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": 4})),
("sketch.slot.overall", json!({"p0": [0, 5], "p1": [10, 5], "width": 4})),
];
for (id, p) in &base {
assert!(issues(id, p).is_empty(), "{id} {p}");
@@ -330,3 +355,34 @@ fn rectangles_and_polygons_report_wrong_typed_keys() {
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);
}
/// The slot replies keep the shape the handlers returned before the schemas: exactly the keys
/// `curves`, `constraints` and `sketch` of the old `result()` helper, plus the `created` summary
/// that `Session::execute` adds to every sketch command, and no `param_warnings`. Pinned by hand
/// from the code on `origin/main`, so it does not depend on the checked path under test.
#[test]
fn slot_replies_keep_their_old_shape() {
let mut s = session(ParamCheck::Off);
for (id, p) in [
("sketch.slot.center_to_center", json!({"p0": [0, 5], "p1": [10, 5], "width": 4})),
("sketch.slot.overall", json!({"p0": [0, 20], "p1": [10, 20], "width": 4})),
] {
let r = run(&mut s, id, p);
let o = r.as_object().unwrap_or_else(|| panic!("{id}: {r}"));
let mut keys: Vec<&str> = o.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["constraints", "created", "curves", "sketch"], "{id}: {r}");
for k in ["curves", "constraints"] {
let list = o[k].as_array().unwrap_or_else(|| panic!("{id} {k}: {r}"));
assert!(!list.is_empty() && list.iter().all(Value::is_string), "{id} {k}: {r}");
}
assert_eq!(o["curves"].as_array().map(Vec::len), Some(4), "{id}: {r}");
assert_eq!(sorted_keys(&o["sketch"]), Some(vec!["dof", "solved"]), "{id}: {r}");
assert_eq!(sorted_keys(&o["created"]), Some(vec!["constraints", "curves", "dimensions", "points"]), "{id}: {r}");
assert_eq!(o["created"]["curves"], o["curves"], "{id}: {r}");
}
}
fn sorted_keys(v: &Value) -> Option<Vec<&str>> {
v.as_object().map(|m| m.keys().map(String::as_str).collect())
}
+134
View File
@@ -999,6 +999,140 @@ impl JsonSchema for SketchRef {
}
}
/// A sweep's `orientation`, as `features::sweep_orientation` reads it, with its messages.
/// `null` is the default, as the handler reads it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SweepOrientationParam(pub solidcraft_doc::SweepOrientation);
impl<'de> Deserialize<'de> for SweepOrientationParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
crate::cmd::sweep_orientation(&Value::deserialize(d)?).map(SweepOrientationParam).map_err(|e| D::Error::custom(whole(e)))
}
}
impl JsonSchema for SweepOrientationParam {
fn schema_name() -> Cow<'static, str> {
"SweepOrientation".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "How the profile turns as it follows the path: perpendicular (the default), square to the path and turning with it; parallel, as drawn, moving without turning (the path must keep crossing the profile's plane); or a direction, [x, y, z], {\"x\", \"y\", \"z\"} or {\"direction\": [x, y, z]}, not zero: square to the path, its turn about the path fixed so that the direction's projection onto the profile stays where it was at the start. Matching ignores case.",
"anyOf": [
{"type": "string", "enum": ["perpendicular", "parallel"]},
point,
{"type": "object", "properties": {"direction": point}, "required": ["direction"]}
]
})
}
}
/// A sweep's `twist`: an expression as [`AngleExpr`] reads one, with the handler's message for
/// anything else. Whether it evaluates, and to within four turns, is the handler's to check.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepTwist(pub Expr);
impl<'de> Deserialize<'de> for SweepTwist {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
params::expr(&wrap(Value::deserialize(d)?), "v")
.map(SweepTwist)
.ok_or_else(|| D::Error::custom(whole("`twist` must be an angle or an expression")))
}
}
/// Guide rails' `rail_fit`, as `features::rail_fit_form` reads it, with its message.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RailFitParam(pub solidcraft_doc::RailFit);
impl<'de> Deserialize<'de> for RailFitParam {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
crate::cmd::rail_fit_form(&Value::deserialize(d)?).map(RailFitParam).map_err(|e| D::Error::custom(whole(e)))
}
}
impl JsonSchema for RailFitParam {
fn schema_name() -> Cow<'static, str> {
"RailFit".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
enum_schema(
&["scale", "stretch"],
"How guide rails reshape the profile: scale (the default) grows and shrinks it evenly with the distance from the path to the rail (between the rails); stretch only across to the rail (between the rails). Matching ignores case.",
)
}
}
/// What `features::guide_rails` says about a sweep's or a loft's (`what`) `rails`, as given (not
/// `null`), before it looks a sketch up: its message, or `None`. `default_sketch`: a rail may
/// leave its `sketch` out. A key a rail doesn't take, which the handler ignores, is refused too.
fn guide_rails_problem(v: &Value, what: &str, default_sketch: bool) -> Option<String> {
let Value::Array(given) = v else { return Some(whole("`rails` must list one or two rails: [{sketch, curves: [ids]}]")) };
if !(1..=2).contains(&given.len()) {
return Some(whole(format!("a {what} takes one or two guide rails")));
}
for (i, rail) in given.iter().enumerate() {
match rail.get("sketch").filter(|v| !v.is_null()) {
None if !default_sketch => return Some(whole("each rail needs the `sketch` its curves are in")),
Some(sketch) if crate::cmd::sketch_key(Some(sketch)).is_none() => return Some(whole("`rails[].sketch` must be a sketch id or name")),
_ => {}
}
let curves = match params::string_list(params::UNKNOWN_COMMAND, rail, "curves") {
Ok(curves) => curves,
Err(crate::EngineError::BadParams { msg, .. }) => return Some(whole(msg)),
Err(e) => return Some(whole(e)),
};
if curves.is_empty() || curves.len() > 1000 {
return Some(whole("each rail's `curves` must list 1…1000 curve ids in order"));
}
if let Some(key) = rail.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "sketch" | "curves"))) {
let key = super::check::truncate(key, 64);
return Some(whole(format!("unknown key `{key}` in `rails[{i}]`: a rail takes `sketch` and `curves`")));
}
}
None
}
/// A sweep's guide `rails`, as `features::guide_rails` reads them before it looks the sketches up,
/// with its messages: one or two `{sketch?, curves}`, each rail's sketch the path's when it
/// names none. Kept as given, for the handler. Whether the sketches and curves exist, and that
/// `orientation`, `twist` and `scale` are left out, is the handler's to check.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepRails(pub Value);
impl<'de> Deserialize<'de> for SweepRails {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
match guide_rails_problem(&v, "sweep", true) {
Some(why) => Err(D::Error::custom(why)),
None => Ok(SweepRails(v)),
}
}
}
impl JsonSchema for SweepRails {
fn schema_name() -> Cow<'static, str> {
"SweepRails".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let sketch = generator.subschema_for::<SketchRef>();
json_schema!({
"description": "One or two guide rails, each {\"sketch\"?: the sketch its curves are in (default: the path's sketch), \"curves\": its curve ids in order, 1 to 1000 (one id may be given as text)}.",
"type": "array",
"minItems": 1,
"maxItems": 2,
"items": {
"type": "object",
"properties": {
"sketch": {"anyOf": [sketch, {"type": "null"}]},
"curves": {"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 1000}]}
},
"required": ["curves"],
"additionalProperties": false
}
})
}
}
/// What `features::plane_param` says about `v` as a plane before it looks anything up, as a
/// complete message, or `None`. Names and ids are the handler's to look up.
fn plane_problem(v: &Value) -> Option<String> {
-7
View File
@@ -11,8 +11,6 @@ file.export
file.insert_mesh
file.insert_step
file.new
file.save
file.save_as
file.save_mesh
file.slicers
motion.export_animation
@@ -26,10 +24,7 @@ render.preflight
render.scene
render.start
sheet.export_dxf
sketch.dimension
sketch.draw.dimensioned
sketch.slot.center_to_center
sketch.slot.overall
solid.align
solid.coil
solid.draft
@@ -48,7 +43,6 @@ solid.revolve
solid.shell
solid.split_body
solid.split_face
solid.sweep
solid.thread
surface.blend
surface.extend
@@ -57,5 +51,4 @@ surface.loft
surface.patch
surface.revolve
surface.ruled
surface.sweep
surface.trim
+44
View File
@@ -1137,6 +1137,8 @@ fn hostile_params_never_panic() {
"density",
"names",
"references",
"head",
"head_gap",
];
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs()
@@ -3307,6 +3309,48 @@ fn tapped_and_clearance_holes() {
assert!(s.execute("solid.hole", &json!({"position": [20, 25, 10]})).is_err());
}
/// A counterbore sized to the screw's head: `head` names the standard and `clearance` the size.
/// ISO 4762 M4 has dk 7 and k 4; with the default 0.5 mm gap the recess is Ø8, 4.5 deep.
#[test]
fn counterbore_for_a_cap_screw_head() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 10}));
let v0 = volume(&mut s);
// The material a through hole of diameter `hole` with a counterbore `dk` wide and `k` deep removes.
let removed = |hole: f64, dk: f64, k: f64| PI * hole * hole / 4.0 * (10.0 - k) + PI * dk * dk / 4.0 * k;
let recess = |s: &Session| match s.doc.features.last().map(|f| &f.kind) {
Some(solidcraft_doc::FeatureKind::Hole { hole: solidcraft_doc::HoleKind::Counterbore { cb_diameter, cb_depth, .. }, .. }) => {
(cb_diameter.as_str().to_string(), cb_depth.as_str().to_string())
}
other => panic!("not a counterbore: {other:?}"),
};
run(&mut s, "solid.hole", json!({"position": [10, 15, 10], "clearance": "M4", "head": "ISO 4762"}));
let v1 = volume(&mut s);
assert!(rel(v0 - v1, removed(4.5, 8.0, 4.5)) < 1e-3, "{}", v0 - v1);
assert_eq!(recess(&s), ("8 mm".to_string(), "4.5 mm".to_string()));
// A button head (ISO 7380-1 M4: dk 7.6, k 2.2), a closer gap, and the depth given by hand.
run(&mut s, "solid.hole", json!({"position": [30, 15, 10], "clearance": "M4", "head": "ISO 7380-1", "head_gap": 0.25, "cb_depth": 3}));
let v2 = volume(&mut s);
assert!(rel(v1 - v2, removed(4.5, 8.1, 3.0)) < 1e-3, "{}", v1 - v2);
assert_eq!(recess(&s), ("8.1 mm".to_string(), "3".to_string()));
// The family name parts.insert takes, a close fit, the pitch written out, and no gap at all.
run(
&mut s,
"solid.hole",
json!({"position": [50, 15, 10], "clearance": "M5x0.8", "fit": "close", "head": "socket_head_cap_screw", "head_gap": "0 mm"}),
);
let v3 = volume(&mut s);
assert!(rel(v2 - v3, removed(5.3, 8.5, 5.0)) < 1e-3, "{}", v2 - v3);
// A gap that names a parameter: the recess follows the parameter.
run(&mut s, "parameters.add", json!({"name": "gap", "expression": "0.5 mm"}));
run(&mut s, "solid.hole", json!({"position": [70, 15, 10], "clearance": "M4", "head": "ISO 4762", "head_gap": "gap"}));
let v4 = volume(&mut s);
assert!(rel(v3 - v4, removed(4.5, 8.0, 4.5)) < 1e-3, "{}", v3 - v4);
assert_eq!(recess(&s), ("7 mm + 2 * (gap)".to_string(), "4 mm + (gap)".to_string()));
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "1 mm"}));
assert!(rel(v3 - volume(&mut s), removed(4.5, 9.0, 5.0)) < 1e-3);
}
/// In an inch design, a bare length typed into a feature means inches (stored with the unit);
/// lengths with units and angles are left alone.
#[test]
+4 -7
View File
@@ -1185,11 +1185,8 @@ mod tests {
}
/// OBJ shares the vertices faces meet at and keeps the body name as written, spaces included.
/// The 10 × 20 × 30 box meshes as the planner's grids, no side longer than a fifth of its
/// extent (the 37.4 mm diagonal): its edges in 2, 3 and 5 pieces, so its faces are grids of
/// 2 × 3, 2 × 5 and 3 × 5 cells, 2 · (6 + 10 + 15) · 2 = 124 triangles. Welded, their
/// corners are the 3 · 4 · 6 = 72 nodes of the box's lattice less the 1 · 2 · 4 inside it: 64
/// positions (unwelded, 372), and one normal per face.
/// The 10 × 20 × 30 box meshes as two triangles a face (a flat face needs no interior
/// nodes), 12 triangles on its 8 corners (unwelded, 36), and one normal per face.
#[test]
fn obj_export_is_welded_and_keeps_names() {
let (_, m) = model();
@@ -1200,10 +1197,10 @@ mod tests {
let o = String::from_utf8(export(&st, &[], Format::Obj, "t").unwrap()).unwrap();
let count = |p: &str| o.lines().filter(|l| l.starts_with(p)).count();
assert!(o.contains("\no Red Block\n"), "{o}");
assert_eq!((count("v "), count("vn "), count("f ")), (64, 6, 124), "{o}");
assert_eq!((count("v "), count("vn "), count("f ")), (8, 6, 12), "{o}");
let ids: Vec<usize> =
o.lines().filter(|l| l.starts_with("f ")).flat_map(|l| l.split([' ', '/']).skip(1)).filter_map(|x| x.parse().ok()).collect();
assert!(ids.iter().all(|i| (1..=64).contains(i)));
assert!(ids.iter().all(|i| (1..=8).contains(i)));
}
/// The OBJ export carries the colours of the bodies: the mesh file names the material
+13 -7
View File
@@ -179,7 +179,10 @@ pub(crate) fn plan(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result
.into_iter()
.collect::<Result<_>>()?;
let face_h = |f: usize| sizes.get(f).map_or(rules.max_len, |(h, _)| *h);
let edge_h: Vec<f64> = edge_faces.iter().map(|fs| fs.iter().map(|&f| face_h(f)).fold(rules.max_len, f64::min)).collect();
// A flat face asks for no size (infinite): an edge between flat faces is whole, one beside
// a curved face takes the curved face's size, so both meet at the same nodes.
let edge_h: Vec<f64> =
edge_faces.iter().map(|fs| if fs.is_empty() { rules.max_len } else { fs.iter().map(|&f| face_h(f)).fold(f64::INFINITY, f64::min) }).collect();
let jobs: Vec<(usize, f64)> = edge_h.into_iter().enumerate().collect();
let solved = b.edges.iter().filter(|e| matches!(e.curve, Curve::SurfaceIntersection { .. })).count();
let mut nodes: Vec<edges::Nodes> = crate::par::map("edge nodes", &jobs, solved >= PARALLEL_CURVES || jobs.len() >= PARALLEL_EDGES, |(e, h)| {
@@ -1027,15 +1030,18 @@ fn plan_face(
// Constrained Delaunay in (u, v) scaled to millimetres: equilateral seeds of side `h`,
// refined where triangles leave the surface, are too long or bend too much, then shaped by
// Ruppert refinement.
let planar = matches!(s, EvalSurface::Plane { .. });
let bare = planar && h.is_infinite();
let polys: Vec<&[[f64; 2]]> = ranges.iter().filter_map(|r| uvs.get(r.clone())).collect();
let step = [h / speed[0], h / speed[1]];
let delaunay = || {
let seeds = cdt::lattice(bbox, step, &polys, speed);
// A flat face with no size asked of it: the boundary nodes alone, no interior seeds.
let seeds = if bare { Vec::new() } else { cdt::lattice(bbox, step, &polys, speed) };
let eval = |u: f64, v: f64| s.derivs(u, v).map(|(p, su, sv)| (p, su.cross(sv).normalized()));
let sag = cdt::Sag { eval: &eval, tol, h, turn: rules.turn, scale: speed };
let fill = cdt::Fill {
seeds: &seeds,
refine: Some((sag, MAX_REFINE)),
refine: if bare { None } else { Some((sag, MAX_REFINE)) },
shape: !matches!(s, EvalSurface::Plane { .. }),
lenient: lenient == Lenient::Yes,
};
@@ -1043,7 +1049,6 @@ fn plan_face(
};
// Planar and curved faces alike: a structured grid where the boundary runs along the
// parameter box (a rectangle, a cylinder half), a Coons grid on four corners, else Delaunay.
let planar = matches!(s, EvalSurface::Plane { .. });
// The lenient fill is the constrained Delaunay alone: the caller names the face in the stats.
let (extra, triangles) = if lenient == Lenient::Yes {
delaunay()?
@@ -1051,7 +1056,7 @@ fn plan_face(
let mut us = uv::divide(s, 0, bbox[0], bbox[1], rules, h, MAX_LINES);
let mut vs = uv::divide(s, 1, bbox[1], bbox[0], rules, h, MAX_LINES);
grid::thin(&mut us, &mut vs, grid::MAX_FACE_GRID);
let structured = match (ranges.len(), if seamed { None } else { grid::quad(&uvs) }) {
let structured = match (ranges.len(), if seamed || bare { None } else { grid::quad(&uvs) }) {
(1, Some(q)) => {
// Interior lines: the boundary's own where opposite sides agree (refined where the
// surface turns more between them inside the face), else the division.
@@ -1076,7 +1081,8 @@ fn plan_face(
_ => None,
};
let structured = match structured {
None if ranges.len() == 1 && !seamed => {
// A flat face with no size asked of it is its boundary alone (the Delaunay below).
None if ranges.len() == 1 && !seamed && !bare => {
let fine = |a: [f64; 2], b: [f64; 2]| uv::fine_between(s, rules, h, a, b);
coons::coons(&uvs, &fixed, speed, grid::MAX_FACE_GRID, h, Some(&fine))
}
@@ -1108,7 +1114,7 @@ fn plan_face(
let trimmed = if seamed || strip {
None
} else if planar {
if ranges.len() == 1 { plan_pieces(&face) } else { None }
if ranges.len() == 1 && h.is_finite() { plan_pieces(&face) } else { None }
} else {
plan_pieces(&face).or_else(|| {
let traces = motorcycle::traces(&uvs, &ranges, speed, 0.25 * h);
+12 -7
View File
@@ -1,7 +1,9 @@
//! Target triangle size: one side length per face, so triangles come out about equilateral
//! (well-shaped triangles shade evenly) and as small as the surface needs.
//!
//! A face's size is the smallest of the body's longest side, the length over which the surface
//! A flat face (a plane, or a spline lying in one) has no size: any triangulation of its
//! boundary is exact, so it gets no interior cells and its straight edges no extra nodes.
//! A curved face's size is the smallest of the body's longest side, the length over which the surface
//! normal turns the caller's facet angle (when one is given, see [`Turn`]), and the chord that
//! sags the tolerance; an edge takes the smaller size of its two faces, so a curved face and
//! its flat neighbour meet at the same spacing.
@@ -107,11 +109,12 @@ pub(crate) fn extent(points: impl Iterator<Item = Vec3>) -> f64 {
/// so the size is never larger than the face needs).
pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> (f64, [f64; 2]) {
if let EvalSurface::Plane { du, dv, .. } = s {
return (rules.max_len, [du.len(), dv.len()]);
return (f64::INFINITY, [du.len(), dv.len()]);
}
let (Some(ru), Some(rv)) = (dom.range[0], dom.range[1]) else { return (rules.max_len, [1.0, 1.0]) };
let flat = planar(s);
// A flat spline (sheet-metal walls, chamfers): no curvature to look for, only the speeds.
let samples = if planar(s) { 3 } else { SAMPLES };
let samples = if flat { 3 } else { SAMPLES };
let normal = |u: f64, v: f64| -> Option<(Vec3, Vec3, [f64; 2])> {
let (p, su, sv) = s.derivs(u, v)?;
Some((p, su.cross(sv).normalized()?, [su.len(), sv.len()]))
@@ -141,7 +144,9 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> (f64, [
}
}
let speed = if count > 0 { [speed[0] / count as f64, speed[1] / count as f64] } else { [1.0, 1.0] };
(rules.for_curvature(k_max), speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }))
// A flat face is exact for any triangulation of its boundary: no length cap.
let size = if flat { f64::INFINITY } else { rules.for_curvature(k_max) };
(size, speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }))
}
/// A spline surface whose control points lie in one plane, with positive weights: the
@@ -211,7 +216,7 @@ mod tests {
assert!(!planar(&patch(0.5)));
let rules = Rules::new(0.01, 100.0, THIRTY);
let (h, speed) = face_size(&patch(0.0), &super::super::uv::domain(&patch(0.0)), &rules);
assert_eq!(h, rules.max_len);
assert_eq!(h, f64::INFINITY);
assert!((speed[0] - 4.0).abs() < 1e-9 && (speed[1] - 3.0).abs() < 1e-9, "{speed:?}");
// The twisted patch (a hyperbolic paraboloid) has curvature: a smaller side.
assert!(face_size(&patch(0.5), &super::super::uv::domain(&patch(0.5)), &rules).0 < rules.max_len);
@@ -232,9 +237,9 @@ mod tests {
}
#[test]
fn a_plane_takes_the_longest_side() {
fn a_plane_has_no_size_cap() {
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
let rules = Rules::new(0.01, 100.0, THIRTY);
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, 20.0);
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, f64::INFINITY);
}
}
+36 -21
View File
@@ -69,31 +69,46 @@ fn cut(a: &Body, b: &Body) -> Body {
const PI: f64 = std::f64::consts::PI;
/// Smallest corner angle (degrees) over the mesh.
fn min_angle(m: &Mesh) -> f64 {
let mut least = 180.0f64;
for t in &m.triangles {
let [a, b, c] = t.map(|i| m.positions[i as usize]);
for (p, q, r) in [(a, b, c), (b, c, a), (c, a, b)] {
let (u, v) = (q - p, r - p);
if let (Some(u), Some(v)) = (u.normalized(), v.normalized()) {
least = least.min(u.dot(v).clamp(-1.0, 1.0).acos().to_degrees());
}
}
/// A flat face is exact for any triangulation of its boundary, so a box is two triangles a
/// face whatever its size (it was a grid capped at a fifth of the body's diagonal: 124
/// triangles for 20 x 10 x 5).
#[test]
fn a_box_is_two_triangles_a_face() {
for (name, size) in [("20 x 10 x 5", Vec3::new(20.0, 10.0, 5.0)), ("40 x 30 x 20", Vec3::new(40.0, 30.0, 20.0))] {
let b = ok(crate::box_solid(Vec3::ZERO, size));
let (m, s) = plan_body(&b, 0.05);
assert_eq!(s.planar, 6);
assert_watertight(&m, name);
assert_eq!(m.triangles.len(), 12, "{name}: {:?}", (0..6).map(|i| m.tri_face.iter().filter(|t| **t == i).count()).collect::<Vec<_>>());
assert!(max_chord(&b, &m) <= 1e-9, "{name}: flat faces are exact");
}
least
}
/// Flat faces beside curved ones stay joined: the straight edge between a plane and a fillet
/// takes the fillet's spacing, and the plane's boundary uses those nodes.
#[test]
fn a_box_is_a_regular_grid_on_every_face() {
// 40 × 30 × 20: sides at most 1/5 of the diagonal (10.77 mm), so 4 × 3 × 2 cells.
let b = ok(crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)));
let (m, s) = plan_body(&b, 0.05);
assert_eq!(s.planar, 6);
assert_watertight(&m, "box");
let cells = 2 * (4 * 3 + 4 * 2 + 3 * 2);
assert_eq!(m.triangles.len(), 2 * cells, "two triangles per grid cell");
assert!(min_angle(&m) > 40.0, "{}", min_angle(&m));
fn flat_faces_beside_curved_ones_stay_watertight_and_within_the_chord() {
let tol = 0.01;
let bx = ok(crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)));
let rounded =
ok(crate::fillet(&bx, &[Vec3::new(10.0, 0.0, 5.0), Vec3::new(20.0, 5.0, 5.0), Vec3::new(10.0, 10.0, 5.0), Vec3::new(0.0, 5.0, 5.0)], 1.5));
let pin = ok(crate::cylinder(Vec3::new(10.0, 5.0, -1.0), Vec3::Z, 2.0, 7.0));
let holed = cut(&bx, &pin);
for (name, body) in [("filleted box", &rounded), ("box with a hole", &holed)] {
let m = closed_display_mesh(name, body, tol);
let chord = max_chord(body, &m);
assert!(chord <= tol * 1.05, "{name}: chord {chord}");
// The flat faces carry no interior nodes: far fewer triangles than a grid at the
// curved neighbours' spacing would give.
let (flat, curved) = (0..body.face_count()).fold((0, 0), |(f, c), i| {
let n = m.tri_face.iter().filter(|t| **t as usize == i).count();
match crate::face_surface(body, i) {
Some(crate::FaceSurface::Plane { .. }) => (f + n, c),
_ => (f, c + n),
}
});
assert!(flat < curved, "{name}: {flat} flat vs {curved} curved triangles");
}
}
#[test]
+42
View File
@@ -3903,3 +3903,45 @@ fn compact_output_follows_the_apps_preference() {
let mut s = Server::new(app(vec![not_running])).with_options(crate::Options { compact: true });
assert!(inspect_design_is_compact(&mut s));
}
/// A screw's counterbore through MCP alone (#12321): `describe_command` lists `head` and
/// `head_gap`, `execute` forwards them to `solid.hole` unchanged (the command has no typed
/// contract), the recess measures as the head table says, and a wrong head or gap is refused
/// with the value named, leaving the part as it was.
#[test]
fn execute_forwards_a_screw_head_to_a_hole_and_describes_it() {
let mut s = server();
let d = tool(&mut s, "describe_command", json!({"id": "solid.hole"}));
let params = d["params"].as_str().unwrap();
assert!(params.contains("head?: \"ISO 4762\"") && params.contains("head_gap?"), "{params}");
assert!(d["input"].is_null(), "no typed contract: execute forwards the parameters to the command unchanged: {d}");
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 80, "width": 30, "height": 10, "body_name": "Plate"}}));
// ISO 4762 M4 (dk 7, k 4) with the default 0.5 mm gap: a Ø4.5 bore under a Ø8 × 4.5 recess.
let want = 24000.0 - PI * 2.25 * 2.25 * (10.0 - 4.5) - PI * 4.0 * 4.0 * 4.5;
let r = tool(
&mut s,
"execute",
json!({"command": "solid.hole", "params": {"position": [15, 15, 10], "clearance": "M4", "head": "ISO 4762"}, "expect": {"volume_mm3": want, "tol": 0.01}}),
);
assert_eq!(r["expectation"]["mismatch"], json!([]), "{r}");
assert!(rel(volume(&mut s), want) < 1e-6, "{want}");
// ISO 7380-1 M4 (dk 7.6, k 2.2) with a 0.25 mm gap: Ø8.1 × 2.45.
tool(
&mut s,
"execute",
json!({"command": "solid.hole", "params": {"position": [40, 15, 10], "clearance": "M4", "head": "ISO 7380-1", "head_gap": "0.25 mm"}}),
);
let want = want - PI * 2.25 * 2.25 * (10.0 - 2.45) - PI * 4.05 * 4.05 * 2.45;
assert!(rel(volume(&mut s), want) < 1e-6, "{want}");
for (params, names) in [
(json!({"position": [65, 15, 10], "clearance": "M4", "head": "DIN 912"}), "no head standard `DIN 912`"),
(json!({"position": [65, 15, 10], "clearance": "M4", "head": "ISO 4762", "head_gap": -1}), "`head_gap` must be 0 to 7 mm"),
(json!({"position": [65, 15, 10], "diameter": 4.5, "head": "ISO 4762"}), "`head` needs `clearance`"),
] {
let r = raw_tool(&mut s, "execute", json!({"command": "solid.hole", "params": params}));
assert_eq!(r["result"]["isError"], true, "{r}");
let text = r["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains(names), "{text}");
}
assert!(rel(volume(&mut s), want) < 1e-6, "a refused hole changes nothing");
}
+24 -1
View File
@@ -1,7 +1,8 @@
//! Dimension continuation (`solve_after_value_edit`): the triconnected linkage family (two
//! triangles joined by three links, left to the iteration by the construction) under random
//! dimension edits from its previous solved state, with every failure that remains proven
//! infeasible; and the solve without continuation when nothing it could help with changed.
//! infeasible; an edit the continuation cannot reach, solved in steps; and the solve without
//! continuation when nothing it could help with changed.
use solidcore_record::Vec2;
use solidcraft_geom::prelude::*;
@@ -342,3 +343,25 @@ fn hostile_old_values_never_panic() {
assert!(s.points.iter().all(|p| p.pos.x.is_finite() && p.pos.y.is_finite()));
}
}
#[test]
fn an_edit_continuation_cannot_reach_is_solved_in_steps() {
// Every distance of the linkage changed at once, by 0.6× to 2.3×. The one-jump solve fails
// and so does the continuation from the layout (its branch ends); the edit made in steps
// reaches the new values with both triangles turning as before.
let new = [54.0, 59.0, 72.0, 31.0, 78.0, 76.0, 33.0, 61.0, 82.0];
let mut rng = Lcg(1);
let (mut sk, old) = linkage(&mut rng, 1.0, 1.0, false);
set_distances(&mut sk, &new);
let before = turns(&sk);
let mut jump = sk.clone();
assert!(!switch::without_differential(|| solve(&mut jump)).ok());
let mut edit = sk.clone();
let report = switch::without_differential(|| solve_after_value_edit(&mut edit, &old));
assert!(report.keeps_orientation(), "{report:?}");
assert_eq!(turns(&edit), before);
for (&(a, b), d) in PAIRS.iter().zip(new) {
let measured = edit.points[a + 1].pos.dist(edit.points[b + 1].pos);
assert!((measured - d).abs() < 1e-6, "{a}-{b}: {measured} for {d}");
}
}
+78 -1
View File
@@ -1839,7 +1839,84 @@ pub fn solve_after_edit(sk: &mut Sketch, old: &DrivingValues, stored: Option<&[V
/// geometry restoration are identical at every analysis level.
pub fn solve_after_edit_with_analysis(sk: &mut Sketch, old: &DrivingValues, stored: Option<&[Vec2]>, analysis: Analysis) -> SolveReport {
let old_values: Vec<Option<f64>> = sk.constraints.iter().map(|c| old.get(&c.id).copied()).collect();
solve_from(sk, &Hold::default(), stored, analysis, Some(&old_values), &mut Budget::full())
let before = stored.is_none().then(|| sk.clone());
let report = solve_from(sk, &Hold::default(), stored, analysis, Some(&old_values), &mut Budget::full());
if report.keeps_orientation() {
return report;
}
match before.and_then(|before| solve_in_steps(&before, old, analysis)) {
Some((stepped, stepped_report)) => {
*sk = stepped;
stepped_report
}
None => report,
}
}
/// A value edit solved in [`EDIT_STEPS`] steps (each a few times more of them than the last
/// try), the whole sketch at a time, each step a full solve (seed and iteration) from the last
/// step's solution. Continuation follows one solution branch by the iteration alone, from
/// sub-step to sub-step; that branch can end before the new values where a solve from the
/// values a step before still reaches them keeping every turn (the linkage family in
/// `continuation_tests`). `None` when no try reaches the new values keeping every orientation,
/// when nothing that can be stepped changed, or when the sketch is not solved for the old values.
fn solve_in_steps(start: &Sketch, old: &DrivingValues, analysis: Analysis) -> Option<(Sketch, SolveReport)> {
let changed: Vec<(usize, f64, f64)> = start
.constraints
.iter()
.enumerate()
.filter_map(|(i, c)| {
let new = c.kind.value()?;
let was = *old.get(&c.id)?;
(was != new && was.is_finite() && new.is_finite()).then_some((i, was, new))
})
.collect();
let steppable = |&(i, was, new): &(usize, f64, f64)| start.constraints.get(i).is_some_and(|c| steppable(&c.kind, was, new));
if changed.is_empty() || !changed.iter().all(steppable) || !solved_for(start, old) {
return None;
}
'tries: for steps in EDIT_STEPS {
let mut at = start.clone();
let mut values = old.clone();
let mut last = None;
for k in 1..=steps {
let t = k as f64 / steps as f64;
for &(i, was, new) in &changed {
if let Some(c) = at.constraints.get_mut(i) {
c.kind.set_value(if k == steps { new } else { was + (new - was) * t });
}
}
let from: Vec<Option<f64>> = at.constraints.iter().map(|c| values.get(&c.id).copied()).collect();
let step_analysis = if k == steps { analysis } else { Analysis::StatusOnly };
let report = solve_from(&mut at, &Hold::default(), None, step_analysis, Some(&from), &mut Budget::full());
if !report.keeps_orientation() {
continue 'tries;
}
values = at.driving_values();
last = Some(report);
}
return last.map(|report| (at, report));
}
None
}
/// How many steps [`solve_in_steps`] tries a value edit in, fewest first.
const EDIT_STEPS: [usize; 4] = [2, 4, 8, 16];
/// The sketch's geometry is a solved state for the driving values `old`: there is a state to
/// step from and orientations to keep, as `ValueEdit::starts_solved` decides it per component
/// (a sketch stored unsolved has neither, and its one-jump result stands).
fn solved_for(sk: &Sketch, old: &DrivingValues) -> bool {
let mut was = sk.clone();
for c in &mut was.constraints {
if let (Some(_), Some(v)) = (c.kind.value(), old.get(&c.id)) {
c.kind.set_value(*v);
}
}
let lay = layout(&was, &Hold::default());
let mut x = initial_x(&was, &lay);
let (blocks, _) = build_blocks(&was, &lay, &points_on_curves(&was));
max_abs(&system(&was, &lay, &blocks, &mut x, false).0) < ACCEPT
}
/// The orientations of `stored` that `solved` (the same sketch, its links moved and solved
@@ -330,6 +330,40 @@ fn project_dialog_keeps_geometry_targets_and_can_project_whole_bodies_unlinked()
assert!(!sketch.curves.is_empty());
}
/// Project takes a point of another sketch (#13246): a point on XY, a sketch on a plane above
/// it, and a click on the point in the Project dialog picks it, where it used to do nothing.
#[test]
fn project_dialog_takes_a_point_of_another_sketch_13246() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.point", json!({"point":[30,20],"id":"pt"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let first = h.app.session.doc.features.last().unwrap().id;
h.app.run("sketch.create", json!({"plane":"XY","offset":20})).unwrap();
h.app.run("sketch.point", json!({"point":[-30,10],"id":"own"})).unwrap();
h.app.run("view.home", json!({})).unwrap();
h.app.start("sketch.project");
h.settle();
assert!(matches!(h.app.dialog.as_ref().unwrap().kind, Kind::SketchProject { bodies: false, .. }));
// The sketch's own point is not something Project takes.
h.step(&json!({"click":{"world":[-30,10,20]}})).unwrap();
assert!(h.app.dialog.as_ref().unwrap().inputs[0].items.is_empty(), "{:?}", h.app.dialog.as_ref().unwrap().inputs[0].items);
h.step(&json!({"click":{"world":[30,20,0]}})).unwrap();
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(d.inputs[0].items, vec![Sel::ExternalSketchPoint { sketch: first, id: "pt".into() }], "the point is picked");
let cmds = apply_commands(&h.app, d).unwrap();
assert_eq!(cmds, vec![("sketch.project".into(), json!({"refs":[{"sketch":first,"point":"pt"}],"projection_link":true}))]);
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
h.settle();
assert!(h.app.dialog.is_none());
let state = h.app.session.world_state().unwrap();
let sk = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert!(
sk.sketch.points.iter().any(|p| p.id != "origin" && (p.pos.x - 30.0).abs() < 1e-6 && (p.pos.y - 20.0).abs() < 1e-6),
"the point is projected"
);
}
#[test]
fn apply_commits_once_keeps_values_and_waits_for_new_selection_ok_closes() {
for command in ["sketch.offset", "sketch.mirror", "sketch.pattern.rectangular", "sketch.pattern.circular"] {
+9 -1
View File
@@ -468,7 +468,15 @@ mod tests {
let sag = mesh
.triangles
.iter()
.map(|t| t.map(|i| mesh.positions[i as usize]))
.zip(&mesh.tri_face)
// The side wall's own triangles: the flat caps have corners on the rim too.
.filter(|(_, f)| {
matches!(
solidcraft_engine::kernel::face_surface(&b.body, **f as usize),
Some(solidcraft_engine::kernel::FaceSurface::Cylinder { .. })
)
})
.map(|(t, _)| t.map(|i| mesh.positions[i as usize]))
.filter(|t| t.iter().all(|p| (r(*p) - 10.0).abs() < 1e-9))
.map(|[a, b, c]| 10.0 - r((a + b + c) * (1.0 / 3.0)))
.fold(0.0, f64::max);
+12 -2
View File
@@ -466,7 +466,7 @@ fn extrusion_arrow_tracks_the_current_drag_frame() {
open(&mut h);
let at = h.at(&json!({"handle":"arrow"})).unwrap();
let at = egui::pos2(at[0] as f32, at[1] as f32);
h.app.synthetic_mods.ctrl = true;
hold_free_drag_key(&mut h);
h.app.synthetic.push(egui::Event::PointerMoved(at));
h.frame();
h.app.synthetic.push(egui::Event::PointerButton {
@@ -710,6 +710,16 @@ fn failed_face_extrusion_edit_keeps_context_until_escape_and_preserves_visibilit
assert_eq!(h.app.session.visibility, visibility);
}
/// Hold the free-drag key, the platform's primary modifier: Ctrl on Windows and Linux, Cmd on
/// macOS (#13274). Physical Ctrl alone is not the primary key on macOS.
fn hold_free_drag_key(h: &mut Harness) {
use crate::tool_modifiers::{Held, Platform};
h.app.synthetic_mods.command = true;
for platform in [Platform::Mac, Platform::Other] {
assert!(Held::new(h.app.synthetic_mods, platform).primary, "{platform:?} sees no primary key in {:?}", h.app.synthetic_mods);
}
}
/// A block 12.345678 mm thick under XY (so no round step lands on its underside), a 10 mm
/// square sketched on its top and cut 5 mm into it, a second block beside it whose underside is
/// at -7.777 mm, and the cut open in its editor.
@@ -792,7 +802,7 @@ fn the_extrude_arrow_passes_faces_beside_the_profile_and_in_a_free_drag() {
assert_eq!(hold_arrow_at(&mut h, json!({"world":[5,5,-7.75]})), None);
assert!((dialog_distance(&h) - 7.777).abs() > 1e-6, "{}", dialog_distance(&h));
let_go(&mut h);
h.app.synthetic_mods.ctrl = true;
hold_free_drag_key(&mut h);
assert_eq!(hold_arrow_at(&mut h, json!({"world":[5,5,-12.2]})), None);
assert!((dialog_distance(&h) - 12.345678).abs() > 1e-3, "{}", dialog_distance(&h));
let_go(&mut h);
+23 -6
View File
@@ -516,11 +516,28 @@ fn offscreen_adapter(policy: solidcraft_gpu::select::Policy) -> Option<wgpu::Ada
}
}
/// A device of its own for an offscreen render or check (`None` without a GPU adapter the
/// `SOLIDCRAFT_GPU` policy admits, or when the variable names no policy), on [`offscreen_backends`]. One test at a time starts its
/// instance, adapter and device ([`offscreen_starting`]).
/// The test process's offscreen device, for an offscreen render or check: started once, on
/// [`offscreen_own_device`], and shared by every test after it (#13395). A new device for every
/// render also compiled the viewport's pipelines anew: 28 renders took 55 s, on one device 4 s.
/// `None` when [`offscreen_own_device`] is; only a device is kept, so after a failed start (a GPU
/// briefly out of memory) the next test tries again rather than every later one skipping. A test
/// that loses its device, or changes how the device reports errors or its loss, takes
/// [`offscreen_own_device`] instead.
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
static SHARED: std::sync::Mutex<Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)>> = std::sync::Mutex::new(None);
let mut shared = SHARED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if shared.is_none() {
*shared = offscreen_own_device();
}
shared.clone()
}
/// A device of its own for an offscreen test (`None` without a GPU adapter the `SOLIDCRAFT_GPU`
/// policy admits, or when the variable names no policy), on [`offscreen_backends`]. One test at a
/// time starts its instance, adapter and device ([`offscreen_starting`]).
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) fn offscreen_own_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| eprintln!("no offscreen device: {e}")).ok()?;
let _one_at_a_time = offscreen_starting();
let adapter = offscreen_adapter(policy)?;
@@ -1709,7 +1726,7 @@ mod tests {
#[test]
fn a_lost_device_stops_drawing_and_pictures_fall_back_to_the_cpu() {
use egui_wgpu::wgpu;
let Some((adapter, device, queue)) = super::offscreen_device() else {
let Some((adapter, device, queue)) = super::offscreen_own_device() else {
eprintln!("skipping: no GPU adapter");
return;
};
@@ -1749,7 +1766,7 @@ mod tests {
#[test]
fn the_lost_device_probe_is_clean_on_a_working_device() {
use egui_wgpu::wgpu;
let Some((adapter, device, queue)) = super::offscreen_device() else {
let Some((adapter, device, queue)) = super::offscreen_own_device() else {
eprintln!("skipping: no GPU adapter");
return;
};
@@ -1783,7 +1800,7 @@ mod tests {
#[test]
fn a_frame_that_meets_a_lost_device_is_survived() {
use egui_wgpu::wgpu;
let Some((_, device, queue)) = super::offscreen_device() else {
let Some((_, device, queue)) = super::offscreen_own_device() else {
eprintln!("skipping: no GPU adapter");
return;
};
@@ -785,7 +785,8 @@ fn cylinder_with_cluster(sketch: Value, at: [f64; 2], view: &str) -> Cluster {
gap.x.max(gap.y).max(gap.z) <= 1e-3
};
let (body_tris, face_tris, near_tris) = (m.triangles.len(), face.len(), face.iter().filter(|t| near_points(t)).count());
assert!(body_tris >= 2 * face_tris, "the body has other faces: {body_tris} and {face_tris} triangles");
// Other faces exist (the flat caps are two triangles each now, so the curved side can be most of the body).
assert!(body_tris > face_tris, "the body has other faces: {body_tris} and {face_tris} triangles");
Cluster { h, many, click, body_tris, face_tris, near_tris }
}
+2 -2
View File
@@ -41,7 +41,7 @@ fn an_error_under_capture_is_returned_not_a_panic() {
#[test]
fn a_configure_that_meets_a_lost_device_is_not_taken() {
// The queue is kept: wgpu calls a destroyed device's lost callback only while it has one.
let Some((_, device, _queue)) = crate::gpu::offscreen_device() else { return };
let Some((_, device, _queue)) = crate::gpu::offscreen_own_device() else { return };
let lost = DeviceLost::watch(&device);
let bad = wgpu::BufferDescriptor { label: Some("bad"), size: 3, usage: wgpu::BufferUsages::MAP_WRITE, mapped_at_creation: true };
// On a working device a clean configure is taken and a refused one returns its error.
@@ -88,7 +88,7 @@ fn an_acquire_wgpu_refuses_configures_the_surface_again() {
/// the device goes on working.
#[test]
fn an_uncaught_gpu_error_is_not_a_panic() {
let Some((_, device, queue)) = crate::gpu::offscreen_device() else { return };
let Some((_, device, queue)) = crate::gpu::offscreen_own_device() else { return };
crate::gpu::log_uncaptured_errors(&device);
// No error scope around it: mapped at creation with a size that isn't a multiple of 4.
let _bad =
+13 -8
View File
@@ -2777,13 +2777,19 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
}
// Sketch points of the active sketch (of every shown sketch while a command takes points,
// while idle or projecting, and while 3D drawing snaps to points on other visible sketches).
let wants_points = app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::POINTS != 0);
let external_points =
wants_points || other_sketch_points || (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map_or(0, |i| i.accept);
let wants_points = accept & crate::selection::POINTS != 0;
// An input that takes only points of other sketches (Project, #13246) is offered those and
// not the active sketch's own, which it can't take.
let only_other_points = !wants_points && accept & crate::selection::EXTERNAL_POINTS != 0 && accept & crate::selection::SKETCH_POINTS == 0;
let external_points = wants_points
|| only_other_points
|| other_sketch_points
|| (app.dialog.is_none() && app.tool.as_ref().is_none_or(|tool| tool.cmd == "sketch.project"));
let point_sketches: Vec<u64> = if crate::render_workspace::active(app) {
Vec::new()
} else if external_points {
sketches.clone()
sketches.iter().copied().filter(|sid| !(only_other_points && s.active_sketch == Some(*sid))).collect()
} else {
s.active_sketch.into_iter().collect()
};
@@ -9116,10 +9122,9 @@ mod tests {
for style in 0..4 {
app.ui.visual_style = style;
let scene = build_scene(&app).merged();
// Twelve edges per placed box, in the saved mesh's pieces: no side longer than a fifth
// of the 30 mm diagonal, so the eight 20 mm edges in four and the four 10 mm ones in
// two, 40 segments.
let lines = if style == 1 { 0 } else { 2 * 40 * crate::gpu::LINE_SIZE };
// Twelve edges per placed box, each one segment (a flat face needs no nodes along its
// edges), 24 segments.
let lines = if style == 1 { 0 } else { 2 * 12 * crate::gpu::LINE_SIZE };
assert_eq!(scene.lines.len(), lines, "style {style}: twelve edges per placed box");
assert_eq!(scene.hidden.len(), if style == 3 { lines } else { 0 });
assert_eq!(scene.tris.is_empty(), style == 2, "wireframe draws only the edges");
@@ -243,9 +243,8 @@ fn an_opacity_class_change_keeps_vertices_and_rewrites_only_the_runs() {
assert!(block.bytes.opaque.is_empty() && !block.bytes.glass.is_empty(), "the triangles moved to the glass runs");
let triangles: u32 = block.bytes.glass.iter().map(|r| r.len() as u32).sum::<u32>() / 3;
assert_eq!(block.bytes.glass.len(), 1, "one body of one class is one run, drawn with no index buffer");
// The planner's grids, no side longer than a fifth of the box's 26.9 mm diagonal: edges in
// 4, 3 and 2 pieces, faces of 4 × 3, 4 × 2 and 3 × 2 cells, 2 · (12 + 8 + 6) · 2 triangles.
assert_eq!(triangles, 104, "every triangle of the box");
// Flat faces are their outline alone: two triangles a face.
assert_eq!(triangles, 12, "every triangle of the box");
let vertex = block.bytes.glass.first().map_or(0, |r| r.start) as usize;
let at = vertex * crate::gpu::TRI_SIZE;
let colour = first_vertex(&second, block.bytes.vertices.get(at..).unwrap_or_default()).1;
+1
View File
@@ -31,6 +31,7 @@ L0 `geom` -> L1 `sketch`, `kernel`, `render` -> L2 `doc` -> L3 `io` -> L4 `engin
- Nothing below L5 depends on egui, eframe, winit, wgpu or rfd. The UI crate is swappable.
- Only `crates/kernel` may use `truck`.
- Solidcore only through `solidcore-ops` (STEP too; `solidcore_ops::Order`), `-tess`, `-record`; exceptions: `xtask/solidcore-seam.txt`, shrink only.
- The UI is thin: panels read engine state and act through `app.run(id, params)` or `app.start(id)`.
- A new module's `mod` line goes into the existing sorted group, not a new group at the end (`cargo fmt` keeps each group sorted).
+1 -1
View File
@@ -80,7 +80,7 @@ Distances are unsigned. Draw the geometry on the correct side first, and the dim
| `sketch.slot.overall` / `sketch.slot.center_to_center` | `p0`, `p1` (overall ends / arc centres), `width` | Ids and dof are in the "Ids" section above. |
| `solid.fillet` / `solid.chamfer` | `edges:[[x,y,z]]`, `radius` / `distance` | The pick snaps to the **nearest** edge. A pick farther than 5% of the body's size from every edge is refused, so check that the face count changed. A pick where two bodies have an edge, shown or hidden (parts touching, the halves of a split), is refused naming both: give `body`. One pick takes the whole tangent chain: a rounded rim needs 1 point, not 8. |
| `solid.shell` | `faces:[[x,y,z]]`, `thickness` | A point on each face to remove, or its name from `body_topology`. Fillet first, except an edge of a removed face or a fillet between curved faces (see Order in `SKILL.md`). Bodies with lofted or swept faces are refused (`offset: only bodies with planes, cylinders, cones, spheres and tori`): hollow a loft with a smaller loft and `operation: cut`. |
| `solid.hole` | `position:[x,y,z]` on a face (numbers or length expressions such as `["40", "width/2", "t"]`, which the hole then follows) + `diameter`, or `sketch` (+ `points`) | `clearance:"M3"`. `depth` defaults to through all. `type` is one of simple/drilled/counterbore/countersink. A `countersink` needs `cs_diameter` and `cs_angle`; a `counterbore` needs `cb_diameter` and `cb_depth`. `position` is a fixed point; to tie it to the part, add `references: [{edge:[x,y,z], distance}]` (up to two straight edges of the face): the centre then sits at those distances from the edges and follows them. |
| `solid.hole` | `position:[x,y,z]` on a face (numbers or length expressions such as `["40", "width/2", "t"]`, which the hole then follows) + `diameter`, or `sketch` (+ `points`) | `clearance:"M3"`. `depth` defaults to through all. `type` is one of simple/drilled/counterbore/countersink. A `countersink` needs `cs_diameter` and `cs_angle`; a `counterbore` needs `cb_diameter` and `cb_depth`, or `head:"ISO 4762"` (socket head) / `"ISO 7380-1"` (button head) with `clearance`: the recess is then the head's diameter plus `head_gap` (default 0.5 mm) each side, and the head's height plus `head_gap` deep. `position` is a fixed point; to tie it to the part, add `references: [{edge:[x,y,z], distance}]` (up to two straight edges of the face): the centre then sits at those distances from the edges and follows them. |
| `solid.pattern.rectangular` | `features`, `dir1`, `count1`, `spacing1` (+ `dir2`, `count2`, `spacing2`) | Spacing takes expressions and follows parameters. |
| `solid.pattern.circular` | `features`, `axis` (`"Z"`, `{origin, dir}` whose origin may be length expressions the pattern follows, or `{face: [x,y,z]}` / `{edge: [x,y,z]}` on a cylinder wall or circular edge, which the pattern follows), `count` | `angle` defaults to 360 deg. A full pattern keeps the COM on the axis, so check its radius with a top-view screenshot. |
| `solid.mirror` | `features:[names]` or `bodies:[names]`, `plane` (`XY`/`XZ`/`YZ`, a construction plane, or `{origin, normal}` whose origin may use length expressions) | Follows parameters. A mirror or pattern feature cannot be listed in `features` of another mirror or pattern, but `bodies` can mirror the body it made. The mirror is a new body with the first free default name: `Body1`, `Body2` from `features`, and `Side (2)`, `Side (3)` from `bodies:["Side"]`. Read the name from `created_bodies`. With `combine:true`, a mirror that overlaps the original joins it and makes no new body. `features` copies only the features listed, so a later cut is left out unless you list it too. `bodies` copies the finished body. To name the mirror, use `body.rename {"body":"Side (2)","name":"Side_L"}`. The new name stays when parameters change. |
+7
View File
@@ -0,0 +1,7 @@
# The solidcore crates SolidCraft may use directly, one a line (solidcore docs/layers.md §1).
# `cargo xtask layers` reads this list; any other solidcore crate needs a line in
# xtask/solidcore-seam.txt. After vendoring, solidcore owns the list as
# solidcore/public-crates.txt (its CODEOWNERS), and the check reads that copy first.
solidcore-ops
solidcore-tess
solidcore-record
+6
View File
@@ -0,0 +1,6 @@
# Direct uses of solidcore crates that are not in the public list (xtask/solidcore-public.txt),
# one a line: `<crate path> -> <solidcore crate> <reason, naming the solidcore issue or plan step
# that removes it>`. `cargo xtask layers` (xtask/src/layers.rs, seam_check) fails on a use this
# list does not name, and on a listed use that is gone: remove its line in the same pull request.
# Never add a line: call solidcore-ops or solidcore-tess (solidcore docs/layers.md).
crates/smlib -> solidcore the kernel wrapper calls the kernel crate directly until the switch commit of the solidcore ops stage 3b plan
+207 -5
View File
@@ -136,11 +136,16 @@ pub struct Dep {
pub kind: DepKind,
/// `true` if the dependency is a workspace member.
pub workspace: bool,
/// `true` if the package comes from tangency-labs/solidcore (its git URL, or the vendored
/// `solidcore/` directory), whatever it is called.
pub from_solidcore: bool,
}
#[derive(Debug, Clone)]
pub struct Crate {
pub name: String,
/// The crate's directory relative to the workspace root (`crates/smlib`).
pub path: String,
pub deps: Vec<Dep>,
}
@@ -247,12 +252,17 @@ fn check_with(crates: &[Crate], classify: impl Fn(&str) -> Option<Class>) -> Vec
}
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
pub fn from_metadata(meta: &Value, root: Option<&std::path::Path>) -> Result<Vec<Crate>, String> {
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
let mut out = Vec::new();
for p in pkgs {
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
let manifest = p["manifest_path"].as_str().unwrap_or_default();
let path = match (root, std::path::Path::new(manifest).parent()) {
(Some(r), Some(dir)) => dir.strip_prefix(r).map(|d| d.to_string_lossy().replace('\\', "/")).unwrap_or_else(|_| name.clone()),
_ => name.clone(),
};
let mut deps = Vec::new();
for d in p["dependencies"].as_array().into_iter().flatten() {
let dname = d["name"].as_str().unwrap_or_default().to_owned();
@@ -262,14 +272,113 @@ pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
_ => DepKind::Normal,
};
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
deps.push(Dep { name: dname, kind, workspace });
let from_git = d["source"].as_str().is_some_and(|u| u.contains("tangency-labs/solidcore"));
let vendored = match (root, d["path"].as_str()) {
(Some(r), Some(p)) => std::path::Path::new(p).strip_prefix(r).is_ok_and(|rel| rel.starts_with("solidcore")),
_ => false,
};
deps.push(Dep { name: dname, kind, workspace, from_solidcore: from_git || vendored });
}
out.push(Crate { name, deps });
out.push(Crate { name, path, deps });
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
/// The allowlist of today's direct uses of solidcore's inner crates.
pub const SEAM_LIST: &str = "xtask/solidcore-seam.txt";
/// The solidcore crates apps may use, one a line (solidcore docs/layers.md §1). After vendoring
/// solidcore owns the list at `solidcore/public-crates.txt`; the rule reads that first and falls
/// back to the xtask copy, so cutover changes no code.
pub const SEAM_PUBLIC: &[&str] = &["solidcore/public-crates.txt", "xtask/solidcore-public.txt"];
/// The crate names in a public-crates file (`#` comments, blank lines skipped).
fn parse_public(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| l.split_whitespace().next().unwrap_or(l).to_owned())
.collect()
}
/// The public-crates text under `root`: the first of [`SEAM_PUBLIC`] that exists.
pub fn read_public(root: &std::path::Path) -> Result<String, String> {
for p in SEAM_PUBLIC {
if let Ok(t) = std::fs::read_to_string(root.join(p)) {
return Ok(t);
}
}
Err(format!("none of {SEAM_PUBLIC:?} exists"))
}
/// Package names that are solidcore's, whichever way they come in. Anything else from
/// tangency-labs/solidcore is caught by its source (`Dep::from_solidcore`).
fn is_solidcore_name(name: &str) -> bool {
name == "solidcore" || name.starts_with("solidcore-") || name == "ssi" || name.starts_with("step-") || name == "nurbs-grid-mesh"
}
/// The seam rule: SolidCraft crosses into solidcore only through the public crates ([`SEAM_PUBLIC`]). Every other
/// solidcore crate, in any dependency kind, must be named in `allow` (lines
/// `crate-path -> solidcore-crate reason`; `#` comments). The list only shrinks: a listed use
/// that is gone is an error too. Returns the messages, sorted.
pub fn seam_check(crates: &[Crate], allow: &str, public: &str) -> Vec<String> {
let public = parse_public(public);
let mut errors = Vec::new();
let mut listed: Vec<(String, String)> = Vec::new();
for (n, line) in allow.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut it = line.splitn(2, " -> ");
let (Some(from), Some(rest)) = (it.next(), it.next()) else {
errors.push(format!("{SEAM_LIST}:{}: expected `crate-path -> solidcore-crate reason`", n + 1));
continue;
};
let mut parts = rest.split_whitespace();
let (Some(to), Some(_reason)) = (parts.next(), parts.next()) else {
errors.push(format!(
"{SEAM_LIST}:{}: `{line}` needs a one-line reason after the crate name, naming the solidcore issue or plan step that removes it",
n + 1
));
continue;
};
listed.push((from.trim().to_owned(), to.to_owned()));
}
let mut used: Vec<(String, String)> = Vec::new();
for c in crates {
// After vendoring, solidcore's own crates are workspace members; their rules are its own.
if is_solidcore_name(&c.name) {
continue;
}
for d in &c.deps {
if !(is_solidcore_name(&d.name) || d.from_solidcore) || public.contains(&d.name) {
continue;
}
let key = (c.path.clone(), d.name.clone());
if used.contains(&key) {
continue;
}
used.push(key.clone());
if !listed.contains(&key) {
errors.push(format!(
"{} ({}) uses solidcore crate `{}` [{:?}]: call solidcore-ops or solidcore-tess instead (solidcore docs/layers.md; `Order` comes via solidcore_ops::Order, not solidcore-convert)",
c.path, c.name, d.name, d.kind
));
}
}
}
for (from, to) in &listed {
if !used.contains(&(from.clone(), to.clone())) {
errors.push(format!("{SEAM_LIST}: `{from} -> {to}` is no longer used; remove it from the list"));
}
}
errors.sort();
errors.dedup();
errors
}
pub fn describe(class: Option<Class>) -> String {
match class {
Some(Class::Layer(l)) => format!("L{l}"),
@@ -286,7 +395,11 @@ mod tests {
use super::*;
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
Crate {
name: name.into(),
path: format!("crates/{}", short_name(name)),
deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w, from_solidcore: false }).collect(),
}
}
use DepKind::*;
@@ -395,6 +508,95 @@ mod tests {
assert!(matches!(v[..], [Violation::SharedDependsOnApp { .. }]));
}
const PUB: &str = "# public\nsolidcore-ops\nsolidcore-tess\nsolidcore-record\n";
fn seam_crate(dep: &str, kind: DepKind, from_solidcore: bool) -> Crate {
Crate {
name: "solidcraft-smlib".into(),
path: "crates/smlib".into(),
deps: vec![Dep { name: dep.into(), kind, workspace: false, from_solidcore }],
}
}
#[test]
fn seam_direct_kernel_use_fails() {
for kind in [Normal, Dev, Build] {
let e = seam_check(&[seam_crate("solidcore", kind, true)], "", PUB);
assert_eq!(e.len(), 1, "{kind:?}");
assert!(e[0].contains("crates/smlib") && e[0].contains("`solidcore`"), "{}", e[0]);
assert!(e[0].contains("call solidcore-ops or solidcore-tess instead (solidcore docs/layers.md") && e[0].contains("solidcore_ops::Order"));
}
for dep in ["solidcore-convert", "solidcore-ssi", "ssi", "step-import", "step-reader", "nurbs-grid-mesh"] {
assert_eq!(seam_check(&[seam_crate(dep, Normal, false)], "", PUB).len(), 1, "{dep}");
}
// a solidcore crate with a name this list does not know, found by where it comes from
assert_eq!(seam_check(&[seam_crate("brand-new-kernel-crate", Normal, true)], "", PUB).len(), 1);
}
#[test]
fn seam_public_crates_pass() {
for dep in ["solidcore-ops", "solidcore-tess", "solidcore-record"] {
assert!(seam_check(&[seam_crate(dep, Normal, true)], "", PUB).is_empty(), "{dep}");
}
}
#[test]
fn seam_allowlisted_use_passes() {
let list = "# today\ncrates/smlib -> solidcore the kernel wrapper, until the switch\n";
assert!(seam_check(&[seam_crate("solidcore", Normal, true)], list, PUB).is_empty());
// listing one crate does not cover another solidcore crate
assert_eq!(seam_check(&[seam_crate("solidcore-convert", Normal, true)], list, PUB).len(), 2);
}
#[test]
fn seam_removed_use_must_leave_the_list() {
let list = "crates/smlib -> solidcore the kernel wrapper\n";
let e = seam_check(&[seam_crate("solidcore-ops", Normal, true)], list, PUB);
assert_eq!(e.len(), 1);
assert!(e[0].contains("remove it from the list"), "{}", e[0]);
}
#[test]
fn seam_public_list_is_data() {
// a crate added to the public list passes; one left off it fails
let c = [seam_crate("solidcore-newthing", Normal, true)];
assert_eq!(seam_check(&c, "", PUB).len(), 1);
assert!(seam_check(&c, "", "solidcore-newthing # ops gained it\n").is_empty());
}
#[test]
fn seam_public_list_prefers_solidcore_copy() {
let dir = std::env::temp_dir().join(format!("seam-public-{}", std::process::id()));
std::fs::create_dir_all(dir.join("solidcore")).unwrap();
std::fs::create_dir_all(dir.join("xtask")).unwrap();
std::fs::write(dir.join("xtask/solidcore-public.txt"), "old\n").unwrap();
assert_eq!(read_public(&dir).unwrap(), "old\n");
std::fs::write(dir.join("solidcore/public-crates.txt"), "new\n").unwrap();
assert_eq!(read_public(&dir).unwrap(), "new\n");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn seam_list_lines_need_a_reason() {
let e = seam_check(&[seam_crate("solidcore", Normal, true)], "crates/smlib -> solidcore\n", PUB);
assert!(e.iter().any(|m| m.contains("needs a one-line reason")), "{e:?}");
}
#[test]
fn seam_matches_by_package_name_not_source() {
let meta: Value = serde_json::from_str(
r#"{"packages":[{"name":"solidcraft-smlib","manifest_path":"/w/crates/smlib/Cargo.toml","dependencies":[
{"name":"solidcore","kind":null,"path":"/w/solidcore/crates/solidcore"},
{"name":"solidcore-ops","kind":null,"source":"git+https://github.com/tangency-labs/solidcore?rev=abc"}]}]}"#,
)
.unwrap();
let g = from_metadata(&meta, Some(std::path::Path::new("/w"))).unwrap();
assert_eq!(g[0].path, "crates/smlib");
let e = seam_check(&g, "", PUB);
assert_eq!(e.len(), 1, "{e:?}");
assert!(e[0].contains("`solidcore`"));
}
#[test]
fn metadata_parsing() {
let meta: Value = serde_json::from_str(
@@ -407,7 +609,7 @@ mod tests {
]}"#,
)
.unwrap();
let g = from_metadata(&meta).unwrap();
let g = from_metadata(&meta, None).unwrap();
assert_eq!(g.len(), 2);
let doc = g.iter().find(|c| c.name == "solidcraft-doc").unwrap();
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
+42 -6
View File
@@ -29,6 +29,7 @@ mod ui_containers;
mod web_offline;
mod web_offline_test;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
@@ -197,9 +198,23 @@ fn main() -> ExitCode {
}
/// Workspace root (parent of the xtask crate).
///
/// `cargo xtask` (`cargo run`) sets `CARGO_MANIFEST_DIR` for the process, so that names the
/// checkout the command runs in. The compile-time value is only a fallback for running the
/// binary directly: git worktrees that share one `CARGO_TARGET_DIR` share one `xtask` binary,
/// and its baked-in path names whichever worktree built it last.
pub fn root() -> PathBuf {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
here.parent().unwrap_or(here).to_path_buf()
root_from(std::env::var_os("CARGO_MANIFEST_DIR"), Path::new(env!("CARGO_MANIFEST_DIR")))
}
/// The workspace root from the runtime `CARGO_MANIFEST_DIR` (when set and not empty), else from
/// the compile-time one.
fn root_from(runtime: Option<OsString>, compiled: &Path) -> PathBuf {
let here = match runtime.filter(|dir| !dir.is_empty()) {
Some(dir) => PathBuf::from(dir),
None => compiled.to_path_buf(),
};
here.parent().map(Path::to_path_buf).unwrap_or(here)
}
pub fn cargo() -> Command {
@@ -254,7 +269,7 @@ fn cmd_action_matrix(bless: bool, results: Option<&str>, from: Option<&str>) ->
}
fn cmd_layers() -> Result<(), String> {
let crates = layers::from_metadata(&metadata()?)?;
let crates = layers::from_metadata(&metadata()?, Some(&root()))?;
println!("Dependency layering (plan/architecture.md §3)\n");
println!("{:<28} {:<14} workspace deps", "crate", "layer");
for c in &crates {
@@ -273,10 +288,12 @@ fn cmd_layers() -> Result<(), String> {
.collect();
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
}
let violations = layers::check(&crates);
let mut violations: Vec<String> = layers::check(&crates).iter().map(ToString::to_string).collect();
let allow = std::fs::read_to_string(root().join(layers::SEAM_LIST)).map_err(|e| format!("{}: {e}", layers::SEAM_LIST))?;
violations.extend(layers::seam_check(&crates, &allow, &layers::read_public(&root())?));
println!();
if violations.is_empty() {
println!("OK: {} crates, no layering violations.", crates.len());
println!("OK: {} crates, no layering or solidcore seam violations.", crates.len());
Ok(())
} else {
println!("{} violation(s):", violations.len());
@@ -430,7 +447,7 @@ fn cmd_wasm() -> Result<(), String> {
/// The wasm check and how many crates it covers. It uses the ci profile so the host build
/// scripts and proc macros built for clippy and the tests are reused.
fn wasm_check() -> Result<(Command, usize), String> {
let crates = layers::from_metadata(&metadata()?)?;
let crates = layers::from_metadata(&metadata()?, Some(&root()))?;
let mut set: Vec<String> = crates
.iter()
.filter(|c| matches!(layers::classify(&c.name), Some(layers::Class::Layer(_) | layers::Class::Shared)))
@@ -469,3 +486,22 @@ fn cmd_ci() -> Result<(), String> {
eprintln!("ci: all gates passed");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_prefers_the_runtime_manifest_dir() {
let compiled = Path::new("/built/here/xtask");
let runtime = Some(OsString::from("/run/there/xtask"));
assert_eq!(root_from(runtime, compiled), PathBuf::from("/run/there"));
}
#[test]
fn root_falls_back_to_the_compiled_manifest_dir() {
let compiled = Path::new("/built/here/xtask");
assert_eq!(root_from(None, compiled), PathBuf::from("/built/here"));
assert_eq!(root_from(Some(OsString::new()), compiled), PathBuf::from("/built/here"));
}
}
+1
View File
@@ -81,6 +81,7 @@ pub const SKIPPED_FUNCTIONS: &[(&str, &str)] = &[
("crates/doc/src/eval.rs:feature_json", "fingerprints feature inputs; triangles are only embedded mesh-body source data"),
("crates/doc/src/eval.rs:mesh_bodies", "mesh bodies: a mesh import's triangles"),
("crates/doc/src/eval.rs:mesh_content_digest", "mesh-body imports: hashes source arrays without geometric classification"),
("crates/doc/src/project3d.rs:flat_outline", "mesh bodies: the flat patch of an imported mesh's triangles that a face pick on it means"),
("crates/engine/src/cmd/file.rs:saved_preview", "the preview picture saved in a design"),
("crates/kernel/src/body.rs:Body::classify_point", "mesh bodies: its mesh-body branch (a B-rep is classified by SMLib)"),
("crates/kernel/src/body.rs:Body::mesh_untimed", "the mesher: a body's tessellation"),
+1 -1
View File
@@ -25,7 +25,7 @@ const SCRATCH_TARGET: &str = "target/xtask-shared-target";
pub fn run(root: &Path) -> Result<(), String> {
let meta = crate::metadata()?;
let crates = layers::from_metadata(&meta)?;
let crates = layers::from_metadata(&meta, None)?;
let dirs = manifest_dirs(&meta)?;
let shared: BTreeSet<&str> = crates.iter().filter(|c| layers::classify(&c.name) == Some(Class::Shared)).map(|c| c.name.as_str()).collect();
if shared.is_empty() {