Compare commits

...

132 Commits

Author SHA1 Message Date
bherbruck 5965439a15 Release 0.24.0 2026-10-26 19:23:21 -04:00
bherbruck 0b2d3ecd44 Release 0.24.0: version bump, design format 39 (#7394)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:23:19 -04:00
github-actions[bot] 96e43bb4ef Regenerate generated files and fold pending files [skip ci] 2026-10-26 23:04:43 +00:00
github-actions[bot] 955f5aea43 Regenerate generated files and fold pending files [skip ci] 2026-10-26 22:56:34 +00:00
bherbruck 739e442824 Links: ask before following a design's network paths (#7372)
A path a design names (a linked design, a sketch text's font file) now
goes through one rule before any file call: a network location is
reached only once its server is allowed, a device path never. Update
Links and Open Linked Design ask first (Allow once / Always allow this
server / Cancel); Preferences > Design lists the allowed servers and
can allow every network location. component.links reports such a
link's found as unknown with a reason, without looking for it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:52:43 -04:00
MAKAO 30f951ecf9 Navigation bridge: a device's own app moves the view (#6094)
A homemade 3D input device's driver app (MAKMOUSE) listens on a loopback
port; with Preferences > Navigation > "Navigate with a device app on this
computer" on (off by default), the desktop app connects, says hello and
reads JSON lines: axis motion (orbit, pan or zoom by mode), zoom steps and
standard views. The motion goes through navigation::device like a 3D
mouse's, only in the focused window; a HID device the app names is left
to it. Documented in docs/3d-mouse.md.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 22:14:52 +00:00
github-actions[bot] 02458892d9 Regenerate generated files and fold pending files [skip ci] 2026-10-26 22:30:02 +00:00
amolinari-autotex 1d02a5f2d4 Personal Hole parameter presets (#4480)
* feat: add personal Hole parameter presets

* feat(hole): prompt for preset names and recall changed selections

Ask for a name only on Save new or Rename in the kit confirmation. Keep Enter and Escape inside the prompt, preserve cancelled drafts, recall only changed selections, and retain independent recessed-hole bottom choices.

* feat(hole): preserve thread direction and partial depth in presets

Carry Left/Partial, explicit source units and Custom draft guards through personal preset capture and recall. Preserve legacy Right/Full/flat defaults and reject unknown options before changing the form. This production bridge depends on the handedness and graphical-depth changes inherited below it.

* docs(hole): describe supported preset thread options

* feat(ui): save modified hole presets in place

* fix(presets): retain reference placement and current locale contracts

* test(ui): borrow restored preset library during serialization

* test(presets): preserve modal focus with viewport focus restoration

* Use UI kit controls for hole preset selection
2026-10-26 22:00:56 +00:00
bherbruck c391ef17cb Themes: Clay (light) and Clay Dark, warm paper and charcoal (#7342)
* Themes: Clay (light) and Clay Dark, warm paper and charcoal with a terracotta accent

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

* Clay: on-accent text a touch lighter for 4.5:1 on the accent

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:31:40 +00:00
Nathan Dane e413327821 History: follow the Browser's translucency when docked under it (#6922) 2026-10-26 21:26:28 +00:00
HiroKenEz a1eb63c169 Extrude: the picked region is the one extruded again (#6724) (#6766)
* claim: #6724

* Extrude: the picked region is the one extruded again (#6724)

Since #5530 (#5302) every index or point pick grew to the whole drawn
profile that reference geometry cuts, so a lens or crescent clicked in
Extrude, where the face's projected outline cuts a circle, extruded the
whole circle while the dialog highlighted and counted one region.

Dialogs now send exact_regions: true with their profile picks, and the
command keeps each pick to the region it names. Picks without the flag
(scripts, agents, the CLI) still take the whole drawn profile (#5302).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:02:43 +00:00
github-actions[bot] 610f2e479c Regenerate generated files and fold pending files [skip ci] 2026-10-26 21:16:46 +00:00
HiroKenEz f49cd33db1 Select Other: list what is under the pointer front to back, one body at a time (#5345) (#5509)
* claim: #5345

* Select Other: list what is under the pointer front to back, one body at a time (#5345)

The list was sorted by kind first, so on stacked bodies it showed every
body's face, then every body, mixed across depth. Now each body's rows
stay together (vertices, edges, face, then the body), bodies ordered by
their nearest hit along the pick ray; an edge counts at its point under
the pointer. Sketch overlays stay first, profiles, planes and axes last.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:55:45 +00:00
Colin Faulkingham 4c7087ec16 Disable Perspective while editing a sketch (#2144) (#4291)
* claim: #2144

* Disable perspective toggle while editing a sketch (#2144)
2026-10-26 20:48:21 +00:00
AhmetV f87b657c82 Sketch: keep the dimension box focused after a palette linetype click (#2927) (#3197)
Co-authored-by: AhmetV <344941595+ahmetv10@users.noreply.github.com>
2026-10-26 20:32:10 +00:00
CamWight 64ba396c05 Part properties: one table of keys (#4784) (#4786)
* claim: #4784

* Part properties: one table of keys, each with its scope, check and label (#4784)

SolidCraft's part-property keys (project, part number, revision, author,
organization, description, licence) were declared once but listed again
by hand in each command, error, dialog and window. They are now one
table, metadata::KEYS: each row has the key, where it may be set
(Scope: the design, a component or both), how its value is checked and
the message id of its label. A new key is one row.

Metadata::set is told whose metadata it changes (Owner::Design or
Owner::Component), and a key is taken where its scope covers that
owner. is_well_known, both set commands, document.metadata,
component.metadata, the refusals, the Design Properties dialog and the
Bill of Materials window read the table; the hand-written lists are
gone (WELL_KNOWN, the label match whose fallback named any unknown key
"License", the window's three labels). metadata::BOM_KEYS names, in
column order, the keys a bill of materials lists for each part:
component.metadata returns them, component.metadata.set takes each as a
parameter of its own and the window edits them; a test keeps each one a
row of the table that a component takes. Free properties use the keys'
text rule (metadata::check_text) instead of a copy of it. The params
docs point to document.metadata's properties instead of repeating the
keys.

Every key accepted before still is, on the design and on a component.
Two things change:
- a refused key or a full metadata on a component says "part property"
  ("`bogus` is not a part property: …", "part properties are limited
  to …"), not "design property";
- changing the design's own properties no longer evaluates the model.
  The fields that change no geometry are named once,
  Document::with_part_properties_of: the design's metadata, and each
  component's metadata and free properties. It compares them first and
  copies the design only when one differs, as before.

The shortcut is for a session's own edits only. A command that gives
the session another design (doc.open, file.new, doc.recover: through
Session::replace_design, which now counts them) is refreshed in full,
however little the new design differs from the one before, so its
joints are solved and its occurrence state pruned. Before, opening a
design that differed from the open one only in render settings or
component properties skipped that; with the design's properties in the
list it would have for two revisions of one part too.

Tests: the table (one row per key, the dialog's order); the bill of
materials' keys (rows a component takes, in column order); each scope
on each owner, and refusals that name the owner (the empty key, an
unknown key, a 256-byte key shown cut); the text check at 4096 bytes,
line breaks, tabs and control characters; hostile keys and values
refused without a change; only-part-property changes in each scope,
and changes beside them (units, a component's name, an added, removed
or swapped component); document.metadata.set, remove, undo and redo
evaluate nothing; opening a revision that differs only in its author
solves its joint; the component wording, the shortcuts and the limits;
the dialog's and the window's labels from the table.

What the tests can't reach: every row is Scope::Both, so a known key
refused for its owner (the wrong-owner branch of check) can't happen
yet. It becomes live with the first single-scope row, bom_type, a
component-only key.

Left for later (not this topic):
- Metadata::others() ignores scope, so a component-only key stored on
  the design would be neither a dialog field nor listed under other
  tools' data. It matters from the first single-scope key (bom_type).
- assembly.bom's rows, its CSV columns and the window's grid headers
  still name the three keys by hand; the bill of materials module's
  rewrite will read BOM_KEYS.
- The bill of materials types (P1, #5107, into drawings) still use
  WELL_KNOWN and the two-argument set: once this is on main, bom_type
  is rebuilt on it as one KEYS row with Scope::Component, set through
  Owner::Component.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:03:22 +00:00
Logan Night 1720c56840 Drag arrows: pattern spacing and sketch offset go negative past their start (#5497) (#5508)
* claim: #5497

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

* Sketch pattern: a spacing arrow dragged past zero gives a negative spacing (#5497)

The drag arrow floored an unsigned value at a small positive number, and the
rectangular sketch pattern's two spacing arrows were unsigned, although the
command takes a negative spacing and typing one works. Both arrows are signed
now, like the Extrude, Move and offset ones.

The test drags each arrow back past its start: without the change the first
one stops at 0.001.

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

* Drag arrows: the solid pattern spacing and one-sided sketch offset go negative too (#5497)

The solid rectangular pattern's Spacing arrow and the sketch Offset distance
arrow were unsigned, so a drag back past the start stopped at a small positive
value, although both commands take a negative value and typing one works. Both
are signed now. A symmetric offset uses only the distance's size, so its arrow
still stops at the curve.

Tests: the solid pattern arrow dragged 10 mm back gives Spacing -10 and the
copies on the negative side (without the change: 0.001); the offset arrow
dragged across its circle gives -1 mm and the same circle as typing -1 mm
(without the change: 0.001), and a symmetric offset stays positive.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 20:03:03 +00:00
github-actions[bot] b28decfaf6 Regenerate generated files and fold pending files [skip ci] 2026-10-26 20:30:44 +00:00
bherbruck 9e4dc48165 Release: sign Windows binaries and MSI through SignPath Foundation (#7067)
* Release: sign the Windows binaries and MSI through SignPath Foundation

When the SIGNPATH_API_TOKEN secret exists, each Windows job sends the
three .exe files to SignPath before packaging, then the MSI built from
them, using the GitHub artifact flow from SignPath's docs. The policy
is test-signing unless the signpath_signing_policy dispatch input or
the SIGNPATH_SIGNING_POLICY repository variable says otherwise. Without
the secret the jobs build unsigned exactly as before.

package.ps1 gains -Phase stage|package so signing can sit between the
build and the MSI; the artifact configuration to paste into SignPath is
packaging/windows/signpath-artifact-configuration.xml.

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

* SignPath: drop the description attributes the Foundation plan doesn't allow

SignPath Foundation projects get an automatic signature description (its watermark);
the artifact configuration may not override it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:47:06 +00:00
bherbruck 3de2f96b7f Sketch dimensions: double-click a shown dimension in the model view to edit it (#7079) (#7080)
* Double-click a shown sketch dimension in the model view to edit its value (#7079)

A finished sketch's dimensions shown in the model view (Show Dimension) now take a
double-click: the same inline value box as sketch editing opens at the text, Enter
changes the dimension's own expression through parameters.change (one undo step), Esc
cancels. The sketch is not edited and the view does not move. Driven dimensions stay
read-only with a status hint.

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

* Control channel: ui.selection lists the shown dimensions of finished sketches

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

* Test: a shown dimension that breaks a later fillet reports it, and Undo restores both

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:47:04 +00:00
Jesper We 436ee95095 Preference: return to the previous view when a sketch ends (#7038) (#7043)
* claim: #7038

* Preference: where the view goes when a sketch ends (#7038)
2026-10-26 19:31:55 +00:00
github-actions[bot] 1ee2532678 Regenerate generated files and fold pending files [skip ci] 2026-10-26 20:00:04 +00:00
github-actions[bot] 3152e02fe5 Regenerate generated files and fold pending files [skip ci] 2026-10-26 19:51:41 +00:00
Nathan Dane fbcd4852c1 Navigation bar: the view commands (#6585) (#6586)
* claim: #6585

* Navigation bar: the view commands (#6585)
2026-10-26 17:46:15 +00:00
HiroKenEz 97cce94a1c Agent chat: tool calls read as summaries, raw JSON behind Details (#1457) (#6245)
* claim: #1457

* Agent chat: tool calls read as summaries, raw JSON behind Details (#1457)

Each tool-call card now says what the call did, built from the command's
translated label, its main parameters (lengths in the design's units) and the
bodies the result names: "Extrude Sketch1 by 10 mm", "Box 40 x 30 x 20 mm,
then Fillet 4 edges (r 4 mm)", "Measured Block: ...", "Saved block.solidcraft".
Calls it can't put in words keep the agent's title. The raw arguments (now
kept from the in-app client and ACP's rawInput) and the result sit under a
folded "Details".

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

* Agent chat cards: show file names, never paths; temp files not named

ui.screenshot reads "Screenshot"; save/export/open and other tools' titles
show only the file name. The full text stays under Details and in the tooltip.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-26 17:22:17 +00:00
Victor Santos Oliveira 5b41d61f1e Tests: exercise home-path tooltips on Windows (#6890) (#6891)
* claim: #6890 portable home-path tooltip test

* test(ui): exercise native home paths on Windows (#6890)
2026-10-26 17:04:43 +00:00
Dahak Builds 2863b80574 Sketch: 3D arc command (#5681) (#5682)
* claim: #5681

* sketch: exact circular arcs on drawn 3D wires (#5681)

Add WireArc (centre, unit normal, radius, start/end) as an optional analytic
form on drawn 3D sketch wires, with arc_from_3_points, arc_sweep and
sagitta-bounded arc_polyline; add_drawn_arc_in stores the arc, its three
defining fit points and its samples; move_drawn_point re-derives the arc
atomically and refuses collinear points; validate_wires checks saved arcs.

* engine,ui: sketch.arc3d exact 3D arc command (#5681)

sketch.arc3d draws an exact circular arc through three world points in a 3D
sketch, stored analytically beside its fit points and samples; sketch.move3d
rebuilds it, sketch.inspect reports centre/radius/sweep; placement mapping
(map_wire, now shared with the engine's world state) transforms the arc.
One pending design-format file records the new field. The viewport's 3-Point
Arc tool draws a 3D arc when clicked off the sketch plane, with a live arc
preview; the SKETCH panel offers sketch.arc3d.

* ui,engine: 3D Arc button starts an interactive tool (#5681)

sketch.arc3d now maps to a Draw(3) tool handled by sketch3d: three world clicks (start, end, through - same order as the 2D arc tool) run the command, with the live arc preview and snapping. The command is only enabled while a 3D sketch is open, so the toolbar never offers a tool that could only fail. Locale labels for the command.

* ui: cargo fmt

---------

Co-authored-by: RayGalt <RayGalt@pm.me>
2026-10-26 16:29:39 +00:00
yhamil04 03a701d678 Viewport tests: the Metal hover pixel test keeps its pictures when it fails (Refs #1383) (#6805)
* claim: #1383

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

* Viewport tests: the hover pixel test keeps its pictures when it fails (#1383)

The Metal hover pixel test failed once in a loaded full-suite run and has
not been reproduced since. On failure it now saves the plain and hovered
GPU and CPU frames and a mask of the pixels that do not light, and names
the folder in its message. The assertion is unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:01:39 +00:00
github-actions[bot] a2ccbd0684 Regenerate generated files and fold pending files [skip ci] 2026-10-26 19:21:19 +00:00
Daniel 2e566e2c58 Placement handles for Rest, Boss and Snap Fit (#4384) (#5362)
* claim: #4384

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

* Placement handles: shared module, with Rest as the first tool

The Rest handles spike becomes a shared module, place_gizmo, so other features placed on a face can
use the same marker and knob. The mechanics (crosshair with its three states, the grab offset, the
footprint while dragging, the turn knob) live in place_gizmo.rs. The handles own the pointer through
canvas::owns_pointer, as the move triad does, so navigation and Select Other's press-and-hold leave
them alone. Each tool is one file under place_gizmo/ that gives a Spec: which dialogs it serves,
which commands' Position input also takes sketch points, where the place is, how the pointer
re-places it, its footprint and its turn, if any. The module lists the tools one per line, so tools
added on separate branches each add one `mod` line and one list line. Rest is the first.

The handles are always on; the preference the Rest spike had is gone. The POINTS accept flag on a
Position input is granted from the tools' Specs instead of a Rest-only arm in the plastic dialog's
start. Handles are published as `rest:position` and `rest:rotate` as before.

Seen straight down, the dialog's height arrow collapses onto the place and its value box sits just
right of it, where it took the press meant for a knob pointing that way. The knob now slides out
along its ray until it is clear of the box.

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

* Placement handles: Boss

The marker moves a boss across the model, also from Edit Feature, and its Position input takes
sketch points while the handles are on, so a screw boss can be dragged onto the centre of a
circle drawn for it. A boss is round, so it gets no knob; the footprint while dragging is its
outer circle. Published as `boss:position`.

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

* Placement handles: Snap Fit

The marker moves a snap fit across the model and its Position input takes sketch points while
the handles are on, so the arm can be put where a sketch says (#3423). The knob points the
catch: it turns between the four directions the Catch Direction combo offers for the face, so
the knob and the combo always agree; the command keeps only those four, so there is no free
turn. The footprint while dragging is the arm's foot, its thickness behind the place and its
width across. Published as `snap_fit:position` and `snap_fit:rotate`.

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

* Placement handles: the look in one file

Everything that draws the handles (the crosshair and its three eased states, the knob's ball, the
cased lines, the angle label, the colours) moves from place_gizmo.rs into place_gizmo/look.rs, so a
restyle touches one file and the mechanics (hit radius, drags, knob geometry) stay where they are.
No change in behaviour.

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

* Placement handles: behind a Design preference, off by default

The handles sit behind Preferences > Design > Placement > Placement handles (`placement_handles`,
also through `ui.prefs`), off by default as the hole placement work in #2912 is: a Rest, Boss or
Snap Fit is then placed by its click alone, its Position input takes faces only, and no handle is
drawn or published. The tests switch the preference on; one checks the default.

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

* Placement handles: the tests hide the origin through set_origin_shown, as main does since #1849

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

* Placement handles: the move marker as designed in the prototype, drawn with theme tokens

The marker is a dot with four arms to the screen cardinals: short and dotted at rest, longer with open arrowheads when pointed at, solid while dragging, each state growing out of the previous one (360 ms). Sizes are the prototype's. Its core is the icon highlight and its casing the marker halo, the tokens main's hole handle uses, so the literal allowance goes. The tip shows while pointing, not during a drag.

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

* Placement handles: the dialog's drag arrow stands above the move marker

With the handles on, the Height arrow started at the marker's centre and ran through its upper arm, head on arm tip. The arrow now keeps the marker's full reach and a gap free at the base, its shortest length grows by as much, and the base dot is left to the marker. Other dialogs are unchanged (clearance zero).

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

* Placement handles: a turn ring on the face replaces the knob

The ring lies on the face about the move marker's centre, drawn in perspective, a fixed 65 px on screen like the move triad's arrows (it is a control of something on the screen, not a measure of the model). At rest it is a short dotted arc with an open chevron grip just outside; pointed at, the dotted circle opens from the grip both ways round and the grip slides in and fills; while turning, a solid arc around the grip. Dragging the grip turns the feature as the knob did, through the same handle name, so the harness tests stand.

The floating value box yields: with a handle's centre under it, it moves to the other side of the arrow's tip or above it, since seen straight down the box sat exactly where the grip is. A handle the arrow's stem crosses keeps the pointer.

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

* Placement handles: a compass while turning, and the turn pulled onto the face's compass points

Four ticks just outside the ring at the face's in-face world directions (the ones the Snap Fit combo names, +X, −Y, …) appear while turning and stay put, so you can see how far you have gone. A Rest's turn is pulled onto one of them within 8°, otherwise it steps by 5° as before; Alt or Ctrl frees it. The readout beside the grip names the point it sits on ("90° · +X") and keeps clear of the ring.

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

* Placement handles: the footprint in the preview's blue, the idle handle steps back

The footprint drawn while the marker drags is a filled shape in the preview's own blue with a thin darker edge, not a white outline, so white stays reserved for what can be grabbed. While one handle is dragged the other fades to a quarter and comes back when the drag ends.

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

* Placement handles: the ring in the preference text, the docs and the tests' wording

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

* Placement handles: the look as tuned in the app

Marker: drawn to the redrawn design (dot r 3.9 pt, two dashes per arm, a filled head 8.7 × 7.1 pt whose base is where the outer dash began); pointed at, the outer dash becomes the head (its far end narrows to a point, its near end widens); dragging runs the inner dash on into the head as a shaft. Every casing is 0.4 pt outside the edge, for lines, heads and the dot alike.

Ring: the grip and the compass lie in the ring's plane like the circle, so the grip is never skewed against it; the grip is a thick chevron with a hole that flattens into the base as the grip slides onto the ring, and fills; the dotted circle stays while turning, with the solid arc laid over it; opening eases in and takes 0.3 s, leaving is a 0.15 s fade over the resting ring, not the sweep backwards; a drag keeps the angular gap of the press, so the grip does not jump onto the pointer; the compass ticks and the readout's point name are in their axis colours, and the grip takes the axis colour (eased) while aligned with one, with that point's tick left out; free turning keeps a hair of pull (0.5°) so an axis can still be hit; the readout wraps to a half turn.

Value box: with a ring it keeps out of the circle the ring, grip, compass and readout sweep, one place per view, nothing moves when a turn starts; also when the value does not evaluate and there is no arrow.

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

* Placement handles: the readout shows a half turn as 180°, not −180°

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

* Placement handles: the motions in one place

The animations the handles grew (a factor eased in and out each way on its own time and easing, a handle stepping back while the other works, a colour that comes and goes from the last one it was on, the gap a press keeps for its drag, an offset that slides instead of jumping) move out of place_gizmo into motion.rs, so the next manipulator reuses them. The look's timings become Transitions in look.rs. No change in behaviour: the contact sheet before and after is identical, the harness tests stand.

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

* Placement handles: the axis colours as main names them since #4430

The compass and the aligned grip take the one set of X, Y and Z colours (axis_x, axis_y, axis_z); the Move gizmo's own array is gone.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 15:35:10 +00:00
amolinari-autotex 47285ccb88 Thread: right- and left-handed direction (#4414) (#4415)
* claim: #4414

* feat: persist right and left thread handedness (#4414)

* fix(thread): verify handedness with referenced holes and complete locales

* test: verify handedness with merged tapped thread catalogues

* test: retain tapped thread depth when changing handedness (#4414)

* test: restrict Unix symlink coverage to Unix targets

* test: inspect hole thread depth inside edit command parameters (#4414)

* test(doc): initialize cylinder radius in handedness fixture
2026-10-26 13:57:08 +00:00
github-actions[bot] da16362626 Regenerate generated files and fold pending files [skip ci] 2026-10-26 18:45:42 +00:00
amolinari-autotex 13cd45d1f1 Apply complete feature transactions off the window thread (#3133) (#3135)
* claim: #3133

* Apply complete feature transactions off the window thread

Evaluate dependent history and prepare reference and GPU display caches before accepting a guarded result. Preserve the evaluated original model for safe editor cancellation and journal only accepted commands.

* fix(ui): preserve original marker across sketch-to-feature edits

* test(ui): verify edit-sketch cleanup and guard Unix link fixture

* test(ui): fix texture disposal and unreachable assertion in feature tests

* fix(build): compile CLI link inspection only on Unix
2026-10-26 13:27:04 +00:00
Nathan Dane c5e63b7871 macOS DMG: blueprint window with larger icons and a drag arrow (#5831) 2026-10-26 12:50:24 +00:00
Steven Waters 2755c9ccfb Pin auto-generated body names (#6402) (#6403)
* Pin auto-generated body names

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

* Fixed body counter

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

* Fixed formatting

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

* Altered test behaviour to reflect new pinned body names

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:33:58 +00:00
SS-EN 4ab6d92952 Agent chip: the batch step and its command, centred above the navigation bar (#5748)
While an agent's batch computes off the window thread (#1989), the chip reads
N/M · <command>. It is centred on the navigation bar, just above it, and moves up
past any toast or panel it would cover.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:28:30 +00:00
froglemonade2 e063a7778e Axes and points from circular edges and round faces (#3128) (#4610)
* Axes and points from circular edges and round faces

In 3D, a round edge or face stands for its circle wherever a command
takes an axis or a point.

Revolve, Surface Revolve, both Circular Pattern dialogs, Plane at Angle
and Move -> Rotate take a circular edge (through its centre, along its
normal) or a cylinder, cone or torus face (its axis). The features keep
the pick as a model reference, so they follow the model when a parameter
moves it. Straight edges work as before; flat faces are refused.

Every 3D point input takes a circle's centre: construction points (which
follow the circle), To Object, patch points, hole placement, and Move's
pivot and points. Hovering shows the axis as a dashed construction line
or the centre as a star, and the dialog's chip names what the pick
stands for ("centre of edge 10", "axis of face 3").

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

* Format the round-pick hover check

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

* Selection chips: name a round pick as a circle centre or axis

The chip has room for little more than the body and a word or two:
"Shaft · circle centre", "Shaft · circle axis", "Shaft · face 2 axis",
and "Shaft · point" for a point of the model.

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

* Move: a circle's centre in a placed component is where the component shows it

Move's pivot and points read a picked circle from the model in its
component's own frame, while the pick is in the world. They now read it
from the world state (and, editing a Move, from the state before it,
placed the same way), as the viewport and the other round picks do.

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

* Plane at Angle on a planar face about a model edge: test that it follows both

Upstream #1956 made a planar face a base for Plane at Angle; this branch makes a model edge an
axis. The rebase keeps both in one PlaneRef::AtAngle.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:27:32 +00:00
Dahak Builds 8630a3f0e5 External STEP corpus: nightly import checks (#1397) (#6407)
* claim: #1397

* External STEP corpus: nightly import checks on real-world files (#1397)

tests/external-corpus.toml pins 20 real-world NIST PMI STEP files (AP203
geometry-only and AP242 editions from several vendor exporters, public
domain) by archive and per-file SHA-256 with the licence recorded.

cargo xtask external-corpus downloads each source once into the gitignored
corpus/external/, verifies every hash, imports each file headless
(file.insert_step + inspect.measure) and checks body count, total volume
(0.1 %) and area (0.5 %) within tolerances and time and memory budgets.
A file whose upstream hash changed is reported as 'source changed', never
as our failure. --bless "why" re-records expectations in the manifest with
a dated reason. The External corpus workflow runs it nightly.

assets: skip corpus/ when scanning for attribution (downloaded case data
is never committed).

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 12:26:36 +00:00
dependabot[bot] 0e0c23b080 Bump pillow from 12.3.0 to 13.0.0 in /assets/render/starter/tools (#6409)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.3.0 to 13.0.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.3.0...13.0.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 13.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-26 12:26:15 +00:00
semmelrinde e29075376c Curvature Map: a scale that lightens steadily from flat to tight (#975) (#6136)
The blue, green, red scale's lightness rose and then fell (L* 44 up to
72 and back to 50), so a flat face and a tight radius looked alike with
hue lost, and its upper part merged for a red-green loss. The stops are
now dark blue, a middle grey-gold and light yellow in one straight
blend, as measured in the issue: L* rises the whole way and every
quarter step stays clear for normal, protan, deutan and tritan vision.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:25:11 +00:00
Karl Von Richter 44b0056d48 Frames: a Weldments ribbon tab (#4003) (#5262)
* Frames: a Weldments ribbon tab (#4003)

- WELDMENTS joins the ribbon after SHEET METAL and is built the same way:
  Sketch (New Sketch), Create (Frame Member) and Configure (Parameters,
  Configurations). Frame Member's home moves there from MODEL > Create.
- Always shown, as a frame starts from a sketch of its lines. It selects
  itself and takes the contextual style when the selected body (or the last
  body of the active component) is a frame member; sheet metal comes first.
- It uses the default accent until the palette has a colour for it.
- The tab label is in every catalog; docs/ribbon-layout.md, docs/frames.md
  and the tab list in docs/control-protocol.md follow.

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

* Frames: Weldments keeps the ordinary tab look (#4003)

Weldments still selects itself when a frame member is the active body, but
no longer takes the solid contextual fill or the 2 px accent line under the
tab row: selected, it looks like a selected Model tab. Only Sketch and Sheet
Metal (on a sheet body) are contextual.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:24:38 +00:00
ivisaf 3bcf77ee32 Expressions: a number and its unit are one value (10 mm / 2 mm is 5) (#6331)
* Expressions: a number and its unit are one value (10 mm / 2 mm is 5)

A unit written after a number was multiplied in at the precedence of `*` and
`/`, so `10 mm / 2 mm` was (10 mm / 2) mm = 5 mm² and `90 deg / 1 rad` a value
in angle², reported as a unit error. The number now takes the unit after it
before `*` and `/` apply; a fraction of numbers before a unit stays one value
(`1/2 in` is half an inch), and an exponent stays a plain number (`2^3 mm`).

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

* Expressions: after a division, a fraction before a unit only when closed up

`width / 10 / 2 mm` read the `10 / 2 mm` as the fraction 5 mm and gave
width / (5 mm) = 8 instead of (width / 10) / 2 mm = 2. After a `/` the
fraction rule now needs the fraction written without spaces (`1 in / 1/2 in`
is still 2); elsewhere `1/2 in` and `10 / 2 mm` are unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:24:34 +00:00
Victor Santos Oliveira 91638cb1f3 Add practical command guides and a contribution template (#5237) (#6772)
* claim: #5237 command-level guide follow-up

* docs: add practical command guides and resource contributions (#5237)

* docs: index practical guides and reject incomplete command links
2026-10-26 12:24:23 +00:00
raykholo 66b872a749 Web app: open a design from a same-origin path (#6670)
?open= now also accepts an absolute path on the page's own origin, fetched
with the page's scheme, so a self-hosted setup that serves SolidCraft and
other tools under one origin can link a design on a LAN without a public
HTTPS address. Absolute URLs keep the HTTPS-only rule (a URL on the page's
own origin is also accepted), redirects must end on HTTPS or the same
origin, and protocol-relative, dot-segment, backslash and control-character
values are refused.
2026-10-26 12:23:52 +00:00
github-actions[bot] 2016ba71f4 Regenerate generated files and fold pending files [skip ci] 2026-10-26 18:07:03 +00:00
ChrisNo 7556e23fe7 Reuse existing tabs when reopening saved designs (#2413) (#2414)
* claim: #2413

* Reuse existing model tabs when reopening saved designs (#2413)

* Expect Open Recent to reuse the existing design tab (#2413)

* Update tab-reuse regression for session visibility
2026-10-26 12:04:11 +00:00
semmelrinde 4d06daf87b Patterns: one before the feature it copies follows that feature's parameters (#6726) (#6727)
* Patterns: one before the feature it copies follows that feature's parameters (#6726)

A pattern or mirror of features made with the timeline rolled back comes
before its sources and replays them from their definitions. Its
fingerprint chained only the features before it and its own inputs, so a
change of a source's parameter left its result in place: the box grew,
its copy kept the old length, also after rolling forward.

The chain fingerprint of a pattern or mirror of features now also takes
the fingerprint of each feature it replays (and of their sources, for a
pattern of a pattern), wherever they are in the timeline.

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

* Patterns: a test for a later feature taking a deleted source's name (#6726)

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

* Patterns: fingerprint the replayed sources with the document, as fingerprint now takes it (#6726)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:01:58 +00:00
rlartot c7e53899af Updated AppImage takes the release file name (#3538) (#3658)
* claim: #3538

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

* Updated AppImage takes the release's file name (#3538)

A versioned AppImage name (solidcraft-<version>-linux-<arch>.AppImage) is
replaced by the new release's name, so zsync-based tools keep finding the
update data. Other names are kept.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 11:58:47 +00:00
Nathan Dane 18ca30a311 fix(ui): open Appearance editor from context-menu parent (#5826) 2026-10-26 11:57:04 +00:00
Colin Faulkingham b55f30959d AI Preferences: reflect agent authentication results (#2578) (#4253)
* claim: #2578

* fix: reflect agent authentication in login badges (#2578)
2026-10-26 11:57:00 +00:00
semmelrinde 8b98f6c7e3 Sketch dimensions: a length's label says its unit (#1155) (#6131)
A sketch dimension read Ø40.00, with nothing to tell millimetres from
inches, while the value inputs and the pointer's coordinates show the
unit. Length labels now end with the design's unit (Ø40.00 mm, 1.000 in,
(25.00 mm) for a driven one); angles keep their degree sign.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:56:57 +00:00
bherbruck 3a504f5347 Agents: AGENTS.md becomes a router to focused skills, with a staleness check (Refs #6439) (#6444)
AGENTS.md drops from 16.8k to 3.7k characters: the hard rules, each naming the
check that enforces it, and a table of what to read for each kind of work.
The rest moves into eleven new skills; the five existing ones stay.

cargo xtask skills fails when AGENTS.md or a SKILL.md cites a path, an xtask
command or a skill that does not exist, when the router table and skills/
disagree, or when the router or a new skill grows past its size cap. It runs
in cargo xtask ci and the repository checks job.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:51:19 +00:00
Colin Faulkingham 7f2acbc004 Claim bot: only actual claims reserve issues (#6739) (#6775)
* claim: #6739

* fix(claim): reserve issues only for actual PR claims
2026-10-26 11:50:18 +00:00
Dahak Builds d3720041b6 CI tooling: opt-in bound for local test concurrency (#1188) (#6831)
* claim: #1188

* #1188: split per-binary test threads and matrix workers under SOLIDCRAFT_TEST_JOBS

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 11:50:10 +00:00
Victor Santos Oliveira a3c7a6ff93 CI: let the paused-work bot label pull requests (#6852) (#6884)
* claim: #6852 paused-work PR labels

* fix(ci): permit paused-work labels on pull requests (#6852)
2026-10-26 11:49:59 +00:00
Christopher Koch e6fb3793dc design: a whole-boundaries loop loads in any curve order (#6499) (#6516)
* claim: #6499

* Design: a whole-boundaries loop loads in any curve order (#6499)

---------

Co-authored-by: Framewrk CI <ci@framewrklabs.ai>
2026-10-26 11:29:00 +00:00
CamWight c6d361347f Section Analysis: the cap is marked by true depth, so far edges no longer take a cap band (#6328) (#6332)
With a section shown, the marking pass decided where the cap shows (the stencil) and wrote the
depth everything after it tests against. #1768 gave it the faces' FACE_BIAS for the second job,
so edges draw over their own faces (#1434). For the first job its slope part is unfair: next to an
edge where a back face seen through the cut meets a front face, the front face won a band about a
pixel wide although the back face is nearer. The cap skipped that band, and the edge line showed
through the cut.

#6363 marked those samples again from the back faces, pulled toward the eye as far as FACE_BIAS
pushes. That closes the gaps beside the edges, but the pull also lets back faces win wherever they
lie within the push of a front face, along every body's far silhouettes, and the cap hatches that
band. On bherbruck's block, the band away from the cut went from 58 px orthographic and 534 px
perspective before #6363 to 618 and 780 with it. In the app, his filleted hub showed it in every
frame of a 36-step orbit in both projections.

This replaces that pass. While a section is shown the opaque model now draws in four steps (`Cut`):
1. mark: the faces by their true depth (no push): colour, the stencil and depth;
2. a triangle over the viewport sets the depth back to the far plane, colour and stencil kept;
3. push: the faces again with FACE_BIAS, depth only, so the depth is what unsectioned frames have
   and edges draw over their faces as before;
4. cap: depth only, as #6728 made it; the mark pass has drawn the cap's colour and hatch, as fs_solid
   draws a back face seen through the cut.
The window pass's depth reset and this one are one helper, `depth_reset_pipeline`, for either
depth direction, and its passes are built by #6728's `solid_writes`. Unsectioned frames are unchanged. The CPU rasterizer already chose the cap by
true depth.

Tests (crates/gpu/tests/depth.rs), with the device's float depth and the 24-bit fallback:
- a_cut_hides_the_edges_behind_it (new): bherbruck's block cut at y = -16, home view, orthographic
  and perspective. No edge shows through the cap, and away from the cut the picture is a box of
  just the kept part's: 0 px orthographic and 1 perspective (main: 618 and 780).
- bherbruck's pocket and hub tests from #6363 are kept and pass (pocket 0 px; hub 1 and 2 px over
  its twelve views, as with #6363's pass).
- an_edge_on_a_face_draws_whole_at_any_angle and
  an_edge_just_behind_a_face_stays_hidden_on_a_large_design also run with a section shown.

In the app (safe mode, WARP), with 625 patterned cylinders cut by a section, the frame time is
that of main (#6363): 33.5 against 34.3 ms at the home view, 34.1 against 35.2 ms with the model
filling the view.

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:16:44 +00:00
github-actions[bot] a078d4e7e9 Regenerate generated files and fold pending files [skip ci] 2026-10-26 17:30:10 +00:00
github-actions[bot] c71234edfb Regenerate generated files and fold pending files [skip ci] 2026-10-26 17:24:19 +00:00
Jan Szumiec 00ff63f75e Face pick by point: linear work budget, so bodies with many faces take a sketch (#6720) (#6721)
* claim: #6720

* Face pick by point: linear work budget, so bodies with many faces take a sketch (#6720)

A face picked by its point alone charged the whole mesh once per face, and
face_contains scanned every triangle for each candidate. A block with a
modelled thread (about 5 000 faces) went past the 2 000 000 limit. The
triangles are grouped by face once, each face's test reads only its own,
and the budget charges triangles + faces.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:16:17 +00:00
leValdemar19 82cd5cc52e Sketch Dimension: value box in the document's units (#3812) (#3878)
* claim: #3812

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

* Sketch Dimension: value box in the design's units (#3812)

A dimension added without a value restated what it measured as
"<value> mm", whatever the design's units. In an inch design the
Dimension tool's value box therefore opened at "25.4 mm" next to a
dimension whose text read 1.000 (inches).

Document::design_length writes a measured length in the design's units
("1 in"), with enough decimals to keep it to the micrometre, so a
dimension that restates the geometry still moves nothing. The Dimension
tool, AutoConstrain's dimensions and Toggle Driven (making a driven
dimension driving) use it. Millimetre designs are unchanged.

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

* Move the design-units dimension tests into sketch_ux_tests (review)

The #3812 tests go in the existing sketch dimension test file rather than
a new file of their own (AGENTS.md, #3226).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:16:12 +00:00
Colin Faulkingham 7d7016b356 Insert Design: preserve source part properties (#6624) (#6691)
* claim: #6624

* Preserve source metadata when inserting a design (#6624)
2026-10-26 11:16:07 +00:00
Colin Faulkingham cd66495e14 Preserve plastic rules and hidden bodies through renames (#4421) (#4425)
* claim: #4421

* Fix body metadata and visibility across renames (#4421)
2026-10-26 11:15:31 +00:00
Colin Faulkingham 484f0cf8ae Sketch: keep nested circles ordered when diameters change (#6764) (#6789)
* claim: #6764

* fix(sketch): preserve nested-circle order during dimension edits (#6764)
2026-10-26 11:14:59 +00:00
SS-EN daecea6edf Evaluation cache: charge each state only for the bodies it made, so setting a parameter back restores (#6897)
The evaluation cache's 64 MB budget charged every cached feature result the whole model, though a
state shares every body it did not change with the state before it. A design whose model is a few
MB kept only its last few dozen results, so setting a parameter back recomputed what had dropped
out. Each result is now charged for the bodies and sketches it does not share with the state it
started from.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:14:57 +00:00
Homero Thompson bd38bc40be Sketch: the Midpoint Line tool shows the length and angle while drawing (#6198) (#6401)
* claim: #6198

* Sketch: show the Length/Angle readout while drawing a Midpoint Line (#6198)
2026-10-26 11:14:22 +00:00
Frank 19c8eb846e Crash reports: keep journaling through file.new and doc.open (#6550) (#6551)
* claim: #6550

* Crash reports: keep journaling through file.new and doc.open (#6550)

`Session::replace_design` swapped in the fresh session without its
`crash_journal` flag, and the app turns it back on only at its next frame.
So `file.new`/`doc.open` never recorded how they ended, the commands sent
after them in the same frame (a control-channel or MCP batch) were not
journaled, and a crash report then named `file.new` as the command in
flight. `replace_design` now keeps the flag, like the other session-wide
fields it keeps; only the design on screen has it set.

Test: `commands_right_after_a_new_design_are_journaled` (crash_tests.rs).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:14:20 +00:00
Dahak Builds 6f5f64f2d9 Viewport: perspective while orbiting, orthographic on face-on views (#2682) (#6700)
* claim: #2682

* WIP: perspective-while-orbiting with auto-ortho on face views

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 11:14:14 +00:00
Homero Thompson b2525ed34e Tests: the six timing assertions do not depend on the wall clock (#6917) (#6925)
* claim: #6917

* Tests: the six timing assertions do not depend on the wall clock (#6917)

Six tests asserted on the wall clock. Their result depended on how loaded
the machine was and not only on the code under test: they passed on an idle
machine and failed when the test binaries ran beside each other, which is how
the suite runs (`cargo xtask test` starts every test binary at once).

CONTRIBUTING.md:196 says it: "Tests run beside each other on a busy machine,
so a test must not depend on wall-clock timing."

The change is confined to the six test files the issue names:

- geom: the tangent chain of a helix is a ratio (750 pieces against 3 000)
  instead of a limit of 200 ms.
- io: the placement budget is spent inside the reader, so the clock is gone.
- ui-egui: the classification of many faces is a ratio (900 against 3 600)
  instead of a limit of 10 s.
- engine: the clearance measurement drops its 30 s limit; the boolean that
  gives up reports itself.
- engine: the fillet beside a modelled thread is a ratio (a 1 mm thread
  against an 8 mm one) instead of a limit of 30 s.
- ui-egui: the window frame budget goes from 250 ms to 20 s, the limit #6072
  gave its own probe.

The helper is the one #649 wrote, copied into the crates that need it, with
the noise of a run as a parameter.
2026-10-26 11:14:11 +00:00
Homero Thompson 72a76edcf7 Shortcuts: Ctrl+Shift+S opens Save As in the workspace (#6558) (#6567)
* claim: #6558

* Shortcuts: Ctrl+Shift+S opens Save As in the workspace (#6558)

The Home page already opened Save As with Ctrl+Shift+S, but the design workspace matched Ctrl+S, so the chord saved over the current file instead of offering another one. The workspace now routes the chord to Save As, and the File menu shows that shortcut next to Save As, taken from the command registry.
2026-10-26 11:14:07 +00:00
Colin Faulkingham 710b7a664e Save a mesh larger than the design file once it is compressed (#6600) (#6701)
* claim: #6600

Co-authored-by: Cursor <cursoragent@cursor.com>

* Save an imported mesh that only fits after compression (#6600)

The design file limit was applied to the mesh before Deflate, so a mesh
that had already loaded could not be saved or autosaved.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: validate and version large mesh saves (#6600)

---------

Co-authored-by: Colin Faulkingham <colinfaulkingham@Colins-Mac-mini.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:13:48 +00:00
HiroKenEz 0d32821e9e Top-level cut and join reach bodies in sub-components (#5679) (#6246)
* claim: #5679

* Top-level cut and join reach bodies in sub-components (#5679)

An extrude, revolve or press-pull from a sketch on a face of a body in a
component inside the active one now cuts or joins that body, moved or
turned, in its own component, so every placement shows the change. The
automatic operation probes that body where it is shown. A linked part, or
a face on a further placement of a part placed more than once, is refused
with the reason instead of a silent no-op (#5678).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:13:45 +00:00
Homero Thompson d9c5e4c325 Sketch: a circle drawn at the origin can be moved (#5034) (#6462)
* claim: #5034

* Sketch: a circle centred on the origin can be moved (#5034)

* test(#5034): say what the assertion really checks
2026-10-26 11:13:14 +00:00
Homero Thompson 12fe17d1b7 Modeling: a copied body keeps the appearance of its original (#6423) (#6456)
* claim: #6423

* test: a copied body must keep the look of the body it came from

solid.move with copy: true leaves the copy without the appearance, the
material or the face looks of the source body, so it renders with the
default look. The tests fail for that reason: the copy has no entry in
appearances.bodies, in materials or in appearances.faces.

* fix: a copied body keeps the look of the body it came from (#6423)

A move with `copy: true` pushed a new body but left it with the default look, so the copy came out a different colour than the part it came from. The copy now carries the body's appearance and its material, as the component copy already did.

* test: keep the copy regression to the body look and the material (#6423)

* test: each copied body keeps the look and the material of its own source (#6423)

Two bodies with different appearances and different materials, copied in one solid.move: each copy must keep the look and the material of the body it came from.

Scope of the fix: the look of the BODY and its material are carried over to the copy. The per-face looks are NOT carried over: a face look is keyed by the point on the face, so the copy's faces would have to be matched and their points translated, which is a separate feature.
2026-10-26 11:12:50 +00:00
semmelrinde cf7fbeaba1 Sketch drag: a scripted drag solves the steps past a failed one at once (#6606) (#6607)
A drag that isn't live walks to the cursor in steps, each a held solve
on its own budget seeded on the last solved state. Past a failed step
the walk keeps trying every step (a stretch the geometry can't follow
can be followed by one it can), and each failing solve runs the
numerical solver to its limit: one drag in a 65-command sketch took
7.7-9 s, most of it in failing solves.

A failed step leaves the sketch as it was, so the steps after it all
start from the same state and none changes what the next one sees.
They are now solved eight at once on all cores; those that fail in a
row are taken as tried and the walk goes on at the first that doesn't,
as it would one by one. The drag above takes 3.7-4.3 s and every
command's reply is unchanged. Live frames, whose steps share one
frame's budget, are left as they are.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:12:40 +00:00
semmelrinde e2c9f908f8 Sketch: an offset slot can still be moved by the dimension that places it (#6569) (#6697)
* claim: #6569

* Sketch: an offset slot can still be moved by the dimension that places it (#6569)

An offset arc is drawn round its source's own centre, and its ends are
held while the sketch solves (the offset is rebuilt from its source
afterwards). Its arc condition, both ends as far from the centre, then
only tied that shared centre, and the source arc with it, to the middle
of the held ends: a slot offset by 1 mm lost a degree of freedom, and
the length that places it was refused as conflicting.

The solver and the constructive reading now leave out the arc condition
of an offset arc round its source's centre (Sketch::offset_arc_follows_source),
as #4466 did for the Parallel between an offset line and its source. The
source's own arc keeps the centre; nothing changes without an offset.

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

* Sketch: a test for a rounded rectangle with an offset round it (#6569)

kaievns's case from the review: the offset's corner arcs tied each corner
centre to a 45 degree line, so the height followed the width and every
width change was refused.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:12:15 +00:00
NOWN-DEV fa961eaa31 Sheet metal: a pattern of a cut made in the flat is kept like the cut (#6841) (#6853)
* claim: #6841

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

* Sheet metal: a pattern of a cut made in the flat is kept like the cut (#6841)

A rectangular, circular or path pattern of an extrude cut through sheet metal was refused
("this sheet machining cannot be preserved in its flat material"), though the cut itself is
kept as a cut-out of the flat pattern. The pattern replayed its copies through the generic
boolean, whose sheet check refuses any change the sheet model can't keep; the cut goes through
the sheet's flat machining first.

Each copy of an untapered extrude cut is now machined into the sheets the same way, in its
moved sketch plane (a moved copy keeps the extrude's prism certificate), and then cut from the
other targets. A copy that meets a folded bend is refused as the cut is there. Mirrored copies
(the sketch plane turns over) and patterns of an intersect (which cut with the union of their
copies) keep the generic path.

Fixes #6841.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:12:12 +00:00
sascha2101 aa88360357 Sketch: deleting part of a projected link no longer brings it back (#5098) (#6324)
* claim: #5098

* Sketch: deleting part of a link breaks off the rest instead of rebuilding it (#5098)

Deleting some of a link's curves left the rest linked. The next refresh saw a
different layout and rebuilt the whole link as normal geometry: the deleted
curve came back and the others lost their construction flag (a projected arc
returning as a solid arc, a projected face's lines turning solid).

sketch.delete now breaks the link for what remains, as Break Link does: those
curves stay where they are and keep their flags. Deleting a whole link, and
text links, are unchanged.

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

* Move the #5098 tests up in sketch_project_tests.rs

They sat at the end of the file, where main has since added other
tests; moving them next to the break-link test avoids the merge conflict.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:11:19 +00:00
sascha2101 e1ff3fa9c0 Batch: refuse a step that would fail at its turn before the first step runs (#5853) (#6781)
* claim: #5853

* Batch: refuse a step that would fail at its turn before the first step runs (#5853)

engine.batch and the MCP batch tool checked only a batch's shape up
front. A misspelled command id in a late step was found at its turn,
after every earlier step had run, and then all of it was rolled back: a
160-step batch with a typo as its last step ran for a minute and
changed nothing.

solidcraft_engine::batch_preflight::check finds, before anything runs,
the first step the batch would certainly refuse at its turn: a step
without a command, an unknown command id (the error names the closest
ids, as at the step's turn) or, with strict parameter checking, a call
that breaks its command's typed contract. Anything that depends on the
design is left to the step's turn.

- engine.batch (control channel) checks against the app's registry and
  its session's level; view.pick, which the app answers itself, passes.
- The MCP batch tool checks when the commands run in its own process
  (headless, with --param-check from the backend). A running app may be
  another version with other commands and checks the batch itself; its
  reply's not_run is passed on.
- The refusal is the failing step's reply with completed 0, undone 0,
  not_run listing every step and a note.

Tests that used an unknown command only to make a late step fail now
use a step that fails at its turn (a bad extrude distance), so they
still test rollback, timings and file.new as before.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:11:10 +00:00
yhamil04 3afcd69a5c Joints: a loop whose axes are a hair out of parallel still moves (#6211) (#6602)
* claim: #6211

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

* Joints: a loop whose axes are a hair out of parallel still moves (#6211)

Holes picked on parts are rarely exactly in line. A closed loop through
axes a few hundredths of a microradian apart (a lever, link and a rod on
a cylindrical axle) can't close exactly: it closes to nanometres, and the
gap grows as it turns. The loop solver wanted 1e-7 mm, so it threw such a
closure away (the loop stayed open, its parts at 0 dof) or stopped a drive
or drag after a fraction of a degree.

The conflict check is now 1e-4 mm / 1e-6 rad (it was below what its
message can show), and joint.drive / motion and joint.reach close the
loop at each step as at the start: to the tight tolerance where they get
there, otherwise within the conflict check.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:10:45 +00:00
yhamil04 a3483ed451 Body rename: an imported assembly's other bodies keep their names and components (#2175) (#6615)
* claim: #2175

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

* Body rename: an imported assembly's other bodies keep their names and components (#2175)

An Import keeps its body names by the file's body index, and builds no body for the
file's later instances of a repeated part (`skip`). Renaming a body stored the names by
the body's place in the model instead, so every name after the first skipped instance
landed one slot early (and the new name could land on a skipped slot). The rename looked
right until the design was rebuilt; then those bodies took other names, dropped out of
their components and showed up loose in the import's top component.

`Feature::body_name_slots` maps the bodies a feature builds to their stored slots, and
`Feature::pin_body_names` stores current names through it. The rename, the pinning of
later features' names (rename and suppress) and `Document::body_rename_from` use them.

Fixes #2175

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:10:39 +00:00
yhamil04 d7252f2240 Sketch: constrain to an edge of any body, a mirrored copy too (#6348) (#6617)
* claim: #6348

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

* Sketch: constrain to an edge of any body, a mirrored copy too (#6348)

A constraint tool ignored a click on a body edge, so only edges already in
the sketch (the face it sits on) could be constrained to; a mirrored copy's
edges never were. Constraint commands now take a model edge or vertex
({edge|vertex: [x,y,z], body?}) for an operand and project it first, in the
same undo step, and the pick tools pass a clicked edge or vertex that way.
The link keeps the edge's persistent name, so the constraint follows rebuilds.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:10:35 +00:00
raykholo 30e9b87b48 Render workspace uses the window's graphics backend on Windows (#6592) (#6629)
* claim: #6592

* Keep Windows Render setup on the viewport backend
2026-10-26 11:10:32 +00:00
github-actions[bot] b20f522f5d Regenerate generated files and fold pending files [skip ci] 2026-10-26 16:34:07 +00:00
Mateusz Sych d895fef354 Control channel: a pushed design.changed event for clients that subscribe (#6985) (#6986)
* claim: #6646

* Control channel: document.revision, a design revision and a document id (#6646)

A program that follows the open design asks document.revision and exports
only when the answer moves: document_id names the open design (kept through
edits, saves and renames, another for each design started or opened, never
shared between two tabs or two runs) and revision moves whenever the design,
what is hidden or an uncaptured component position changes. It stays put on
reads, selections and view changes, unlike the session's view revision.

The revision is read lazily: the session remembers the design it last
reported (held weakly, so Arc::make_mut can never change it in place and its
address can't be reused) and takes a new number from one counter for the
whole run when the design is another one. The control method answers at once,
also while a slow command computes; engine.execute reaches the same command
for scripts and MCP.

* document.revision: hash the run's id prefix with the process's random keys

A clock set before 1970 gave every run the prefix 0, so a restarted app could
answer a new design with the id and revision of the old one. The docs no
longer promise an answer at once: a screenshot waiting for an opened design to
be meshed still holds the requests behind it.

* document.revision: its label in the complete catalogs (ja, zh)

* claim: #6985

* Control channel: control.subscribe and design.changed events (#6985)

A connection that sends control.subscribe gets the active design's id and
revision in the reply (as document.revision answers), then a
{"event":"design.changed","params":{document_id, revision}} line whenever
either changes, so a live link need not poll.

The window publishes the design at the end of each frame's logic and
drawing while a connection is subscribed (one atomic load otherwise). Each
subscribed connection waits on that from a thread of its own and writes
the latest after a 100 ms gap, so a burst of changes is one line. Replies
and events share one lock per connection, each written whole (#6390).

* control.subscribe: the reply before any event, a second one refused

The events thread started before the subscribe reply was written, so a
change right after the window's answer could reach the client before the
reply that named the older revision. The subscription now starts holding
the connection's line lock and writes the reply under it.

A second control.subscribe on a subscribed connection is refused on the
connection itself: its answer could arrive behind a newer event. When no
thread or socket is left for the events, the reply says it did not
subscribe instead of ok.

* control.subscribe: a second one does not lose a finished late reply

The already-subscribed answer came before the busy check, so a request that timed out and had finished since lost its reply: control.pending then said the app ended it without one. It now comes after the busy check and drops a finished late reply as every other request does.

* control_server: declare the subscribe guard at the top of the loop

Clear of the lines #6643 changes (its token check moves to where the declaration was).

* control_server: the subscription's items beside serve

Away from the lines next to send, where #6643 adds token_of.

* control test: New on a blank design reuses its tab, so redo the box first

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-26 11:10:03 +00:00
Dahak Builds 070b1414ee Fix manual vertical/horizontal constraints with circle centre (#617) (#6928)
* claim: #617

* fix(sketch): manual Horizontal/Vertical between a circle centre and an edge (#617)

The manual H/V tool silently did nothing when a point and a straight line
were picked or selected together (only two points or a lone line worked),
while auto-constrain inference applied the alignment. A point picked or
selected with a straight line now aligns the point with the line's
midpoint, in the mode the line's dominant axis suggests, through the
existing midpoint alignment in sketch.constraint.horizontal_vertical.

- tools: constraint_preselection consumes [point, line] selections; the
  interactive tool applies the alignment on the second pick (a line).
- context menu: Horizontal/Vertical Constraint items for point + line.
- engine + ui regression tests pin centre-edge alignment and the
  edge-to-centre dimension from #617.

Written by Qwen.

* style: formatting cleanup for #617 files

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 11:09:52 +00:00
n0tfr3shm1lk 111c662fe4 Extend: join a stop that is already another curve's end (#6863) (#6865)
* Extend: join a stop that is already another curve's end

sketch.extend refused a target that coincided with another curve's end
("the extension target coincides with existing geometry") instead of
extending onto that point. The stop is now joined: a new line ends on the
existing point, and a free end grows onto it held by a Coincident. The
preview shows that stop too, so the line it draws is the line the click adds.

A stop at the end of a spline or conic joins that end the same way, and
extending an infinite line now refuses instead of moving one of its two
points.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* Extend: close an arc, take an equal stop, preview with the command's search

An arc with nothing on its path now grows all the way to its own far end and
closes into a whole circle instead of reporting that there is nothing to
extend to.

Two curves stopping the extension equally far are both on the way, so the first
stop is taken instead of refusing to choose between them.

The line preview and the command now share one stop search (`line_aim`), so the
preview can no longer draw a line the click refuses.

---------

Co-authored-by: n0tfr3shm1lk <n0tfr3shm1lk@ukr.net>
Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-26 11:09:42 +00:00
Dahak Builds ab709e6c46 Add base-height and new-body options to Snap Fit (#1994) (#6911)
* claim: #1994

* plastic.snap_fit: base_height and new_body options (#1994)

Base Height extends the snap fit's base below the picked face; New Body
makes the snap fit its own body so it can be patterned or moved. Both
optional, serde-defaulted, old designs replay identically.

* ui: snap_fit length-field defaults list gains the optional base height

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 11:09:30 +00:00
Colin Faulkingham 03bf992bc1 Keep linked components valid through Undo after Save As (#6860) (#6877)
* claim: #6860

* fix: preserve linked component targets across Save As history (#6860)
2026-10-26 11:08:32 +00:00
tr1pp4 25ad6c7266 Browser: a feature's menu edits its profile sketch, suppresses and rolls back (#6673) (#6674)
* Browser: a feature row edits its profile sketch, suppresses and rolls back

A sketch button on feature rows with a profile sketch, and the feature menu gains
Edit Profile Sketch, Suppress/Unsuppress and Roll Back Here. Features behind the
history marker or suppressed are drawn faint, as in the timeline.

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

* Browser: the sketch button sits right after the feature's name

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:05:38 +00:00
HiroKenEz cfb3f52aec cli_link: the symlink test runs on Unix only, so the UI tests build on Windows (#6545) (#6546)
* claim: #6545

* cli_link: the Unix-only link code and its test build on Unix only, so the UI crate builds on Windows (#6545)

slot() is used only by link_in's Unix version, and links_only_where_it_may
makes its own link with std::os::unix::fs::symlink; gate both like link_in.
On Windows the test failed to compile (E0433) and clippy -D warnings flagged
slot as dead code.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:46:43 +00:00
Colin Faulkingham 7c31631ea6 Draft: preserve the selected faces’ body (#6350) (#6446)
* claim: #6350

* fix(ui): preserve the selected faces' body in Draft (#6350)
2026-10-26 10:45:09 +00:00
Frank c13e516cd5 fix: allow SpaceMouse rotation after tilting sketch view (#6687)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-26 10:44:31 +00:00
raykholo c264c7a42a Startup falls back from a Direct3D 12 that can't build the UI pipeline (#6648) (#6682) 2026-10-26 10:44:13 +00:00
HiroKenEz 56f09f3993 Frame rate readout stays beside a right-side pane, not under it (#6755) (#6767)
* claim: #6755

* Frame rate readout stays beside a right-side pane, not under it (#6755)

The readout and its details sat in the viewport's bottom-right corner even under the
floating Assistant. They now move just left of any pane over that corner and come back
when it closes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:43:53 +00:00
Dahak Builds 6e63f8bd97 Construct toolbar regression tests: dialogs ask for picks (#204) (#6681)
* claim: #204

* Construct toolbar regression tests: dialogs ask for picks (#204)

* chore: re-trigger checks (defaults line already present in PR body)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-10-26 10:43:47 +00:00
Christopher Koch b01f361edf AI chat: keep only the newest views in the history, and explain HTTP 413 (#2298) (#2299)
Every view attached to an API-agent message stayed in the history as a
base64 data URL, and the whole history goes with every request (up to 24
per message). Requests grew by one screenshot per message until the
endpoint refused them with HTTP 413, after which every message failed.

Keep the newest two views as pictures and swap older ones for a short note;
the text sent with them and every other message stay. HTTP 413 now says the
conversation is too large for the endpoint and to start a new chat.

Fixes #2298

Main now speaks the Anthropic protocol too, where a view is an `image`
block rather than an OpenAI `image_url` part: drop_old_views trims both,
before trim_history. ui.ai_http_too_large is in every catalog that has the
other ai_http_* messages.

Co-authored-by: Framewrk CI <ci@framewrklabs.ai>
2026-10-26 10:43:30 +00:00
Colin Faulkingham c482dab4a1 Sheet Metal: open the export save picker (#6847) (#6856)
* claim: #6847

* fix(ui): open sheet export save pickers (#6847)
2026-10-26 10:43:01 +00:00
raykholo 189b47c750 Chamfer and Fillet refuse a point where several edges meet (#5173) (#5177) 2026-10-26 10:42:59 +00:00
Colin Faulkingham 1530a8e247 Route left History feature clicks into command dialogs (#6532) (#6734)
* claim: #6532

* Fix History feature picks in command dialogs (#6532)
2026-10-26 10:42:49 +00:00
sascha2101 1d2674ebc4 Sketch: a regular line on a construction line is picked before it (#6396) (#6753)
* claim: #6396

* Sketch: a regular line on a construction line is picked before it (#6396)

Coincident sketch curves tied on pick distance and the one drawn first
won, so a construction line drawn first hid the regular line on part of
it. Construction and centre lines now get half a screen point added to
their pick distance: on a tie the regular curve is hovered and picked,
and a construction line clearly nearer the cursor still wins. Clicking
again and Select Other still reach the construction line.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:48 +00:00
momysnow 10b5cc5914 GPU: an uncaught error on the window's device is logged, not a panic (#5486) (#5667)
wgpu's default uncaptured-error handler panics ("wgpu error: Out of Memory").
The headless picture devices already report instead; the window's device had
no handler. Errors the code expects stay under error scopes.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 10:42:44 +00:00
Kai Evans 41a938e731 Sketch: a fillet stays the arc of its corner when the corner mirrors or a line is dragged through it (#5677)
A line tangent to an arc at the arc's end had no sense: the tangency held
with the line on either side of its touch point, so pinning a line's far
end across the corner left the line cutting back through its fillet, and
when the solver found the mirrored corner the arc kept its stored
direction and became its three-quarter complement, inside out.

The solver now keeps each fillet an arc of its corner. A line tangent at an
arc's end keeps its sense there (it leaves the arc as it did) and the arc
its side of its chord, compared with the positions before an interactive
move or the stored ones. A corner that mirrored gets its arc on the
mirrored side: its ends swapped where the solve ran the arc round the
other way with the lines following, or the arc reflected across the line
that stayed and solved again where a line cut back through. A solve that
would loop the arc the other way round, or fold a line onto it, is refused
as a fold is. A drag's cursor-following steps keep the fillets too, so a
line dragged through its own fillet stops at the arc.
2026-10-26 10:42:41 +00:00
N3MI-DG 9d0b509961 ui_kit slider: cover egui's own handle at the top of the range (#5708) (#5712)
The kit slider adds a real `egui::Slider`, lets egui paint it, then paints an
opaque rectangle over it and draws the kit's track and thumb on top. That mask
was exactly `slider_width` wide, and egui places its handle so the circle's
right edge lands on `rect.right()` at the maximum, so the fill stopped flush
with the handle and its last column survived as a light sliver beside the
value box. The kit's own thumb cannot hide it either: its radius is
`t.space_8` while egui's handle radius is `input_radius`, about two pixels
larger.

Extend the mask one pixel past its right edge. The gap before the editor is
`item_spacing.x`, so nothing else is covered, and only the maximum was
affected: at the minimum the handle's left edge coincides with the mask's left
edge, which a fill does cover.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-26 10:42:38 +00:00
sascha2101 7ae17e6dd5 Sketch display: each sketch samples within its own budget (#6524) (#6838)
* claim: #6524

* Sketch display: each sketch samples within its own budget (#6524)

After a large DXF was inserted, lines drawn in a later sketch vanished
once finished: only their end points showed and could be picked, and
hiding the DXF sketch brought them back.

The display cache had one sampling budget for every shown sketch (4096
curves, 131 072 vertices per model state and view). Sketches are drawn
and picked in timeline order, so a large imported drawing used it all
and every later curve came back empty (BudgetLimited): not drawn, not
pickable, while the points, drawn separately, stayed.

Each sketch now has its own budget with the same limits (curves,
profiles, vertices and nodes). The cache as a whole is capped at 65 536
curves and 16 384 profiles, so memory stays bounded however many
sketches are shown.

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

---------

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:35 +00:00
github-actions[bot] 1cd14e2412 Regenerate generated files and fold pending files [skip ci] 2026-10-26 14:29:06 +00:00
github-actions[bot] 7014b62388 Regenerate generated files and fold pending files [skip ci] 2026-10-26 14:22:28 +00:00
HiroKenEz adc568a715 Context menu: the Appearance flyout closes when the pointer moves to another row (#6657) (#6784)
* claim: #6657

* Context menu: the Appearance flyout closes when the pointer moves to another row (#6657)

Hovering Appearance opened its quick-choice flyout, but resting on a plain
row of the same menu never closed it: only another submenu row replaced it.
Now a flyout closes once the pointer moves onto another row and rests there
for 0.3 s, so a diagonal path to the flyout across a neighbour keeps it open.
A flyout opened with the keys stays open while the pointer is still.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:32 +00:00
Colin Faulkingham 12b0d9dbff Preserve unchanged results when rolling the timeline (#6008) (#6067)
* claim: #6008

* Reuse rolled-back timeline results after cache eviction
2026-10-26 10:42:32 +00:00
semmelrinde 613903229e Patterns: copies of a To Object extrude follow the face it reaches (#2121) (#6093)
A feature's face points are moved onto their named faces before it is
evaluated, so a To Object extrude follows its target face when an earlier
feature moves it. A pattern's or mirror's copies were built from the
feature as stored, with the face where it was picked: after a gear's disc
was made thicker, the tooth reached the new top but its pattern copies
kept the old height. The copies now use the same moved points; the move
is one helper used in both places.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:28 +00:00
Jesper We 0c4eb458df macOS 3D mouse: unregister from the driver when the app ends, so the next launch gets the mouse (#6114) (#6115)
* claim: #6114

* macOS 3D mouse: unregister from the driver when the app ends, so the next launch gets the mouse (#6114)
2026-10-26 10:42:25 +00:00
Homero Thompson b00c432ac9 space-mouse: cover the 32-bit usage item (#6138)
Issue #37
2026-10-26 10:42:21 +00:00
Jesper We 8a28d13161 Perspective view as a command, so a 3D mouse button can switch it (#6143)
* claim: Perspective view as a command

* Perspective view as a command, so a 3D mouse button can switch it
2026-10-26 10:42:18 +00:00
NOWN-DEV 74321f8bbd Sketch Intersect: crossings on curved faces lie on the surface; a plane on a top face cuts it (#6272)
* Test: sketch Intersect keeps its exact cuts, crosses curved faces on the surface, cuts on a top face

Intersect's cut of a box, a cylinder, a sphere and a tilted cylinder, as it is on main, pinned.
Two cases where it is not exact: a plane through a cylinder's rim cuts the mesh's chord (its sides
come out at 6.3159 and 6.3085, not the square root of 40; a hole's walls at 25.0122), and a plane
on a body's top face finds nothing ("projects to nothing on this sketch plane").

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

* Sketch Intersect: crossings on curved faces lie on the surface; a plane on a top face cuts it

Problem: the plane cut that sketch Intersect makes of a body's mesh (project::section) took a
crossing on an edge where a curved face meets another (a cylinder's rim on its cap) from the
merged normals, which are undefined at the crease, so it fell on the chord: a cylinder's sides came
out 0.01 mm off and uneven. A plane through a vertex or on a face counted the vertices on it as
below, so a plane on a body's top face crossed no edge and found nothing.

Solution: the crossings are found by section_segments, which section fits as before: an edge where
a curved face meets another takes the curved face's normals, so the crossing lies on its surface;
a vertex within 1e-9 x the mesh's size of the plane is on it, on one side for every edge (below
when the mesh lies on or above the plane, else above), and an edge ending there crosses at that
vertex, which is an exact point for the fit. Each segment keeps its B-rep face, for a section that
groups them by face. Cuts that were exact are unchanged.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:42:08 +00:00
Colin Faulkingham a67b3bfdf3 Surface Loft: retain profiles from multiple sketches (#6400) (#6844)
* claim: #6400

* fix(ui): retain surface loft profiles across sketches (#6400)
2026-10-26 10:42:05 +00:00
Colin Faulkingham abdf9d79cf Move/Copy: show a handle for the selected rotation axis (#6822) (#6832)
* claim: #6822

* fix(ui): show selected-axis handle for Move Rotate (#6822)
2026-10-26 10:42:01 +00:00
Colin Faulkingham b1bbca97ef Translate the remaining history tooltip text (#4370) (#6905)
* claim: #4370

* fix(ui): translate history tooltips and feature status
2026-10-26 10:41:50 +00:00
Homero Thompson 2ec11cfc3a Docs: how to open the macOS build when the Gatekeeper blocks it (#5393) (#6566)
* claim: #5393

* docs: how to open the macOS build when the Gatekeeper blocks it (#5393)
2026-10-26 10:41:32 +00:00
github-actions[bot] 2205955329 Regenerate generated files and fold pending files [skip ci] 2026-10-26 13:52:28 +00:00
bherbruck bd9d0778f6 CI: keep lavapipe's shader cache for the GPU path tests (#7113)
The first test in each GPU path shard compiles the path pipeline cold
on lavapipe (335-502 s on 16 vCPU, 665 s on 8, against the 600 s setup
budget); the later ones read Mesa's disk cache in about 30 s. Put that
cache on a sticky disk that main commits, so the first test reads it
too until the shaders change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:49:34 -04:00
cronos a0f7529fd6 fix(measure): select imported mesh outlines and endpoints (#7033) 2026-10-26 10:40:41 +00:00
Lars Jansen 4a0d519727 View: OK and Cancel in the corner of the view while a dialog or sketch is open (#6709) (#6714)
* claim: #6709

* feat(view): OK and Cancel in the corner of the view while a dialog or sketch is open (#6709)
2026-10-26 10:40:36 +00:00
Kai Evans ffbcdc614c History and parameter changes: the run here takes every result computed ahead (#7002)
A change computed off the window thread ran here with only the results of the evaluation
ahead's last evaluate primed. A parameter change evaluates twice (before and after its sketch
stores the new solution), so its first evaluate here found nothing to take, and on a design whose
states pass the evaluation cache's budget it recomputed the features on the window thread: the
window froze for seconds after Computing ended. The run now also takes what the evaluation ahead
kept (KeptResults, as an agent's control requests already do).
2026-10-26 10:40:33 +00:00
Thosch 06af6e1107 Pattern dialogs: Quantity is the count in every language, the motion plot keeps its own entry (#6538) (#6733)
The catalogue entry Quantity served both the pattern dialogs' number of
instances and the motion study's plotted quantity. Nine locales had
translated the second meaning, so the rectangular pattern dialog showed
e.g. "Größe" above "Anzahl 2" in German.

The motion study's row now reads motion.plot_quantity (a pending file
that keeps every locale's previous word there). Quantity takes each
catalogue's own ui.quantity text, which the "Quantity 2" row already
shows; nothing is newly translated. The glossary lists Quantity under
words with more than one meaning.

Fixes #6538

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:40:26 +00:00
bherbruck 84b1aeecae CI: GPU path tests in four shards on 16 vCPU (#7096)
On 8 vCPU the first merge-queue run after #7066 failed: one gpu path
test went past the 600 s pipeline-setup budget (665 s; 204 s for the
same test on the PR run) and shard 2 took 35 minutes. lavapipe compiles
on every core, so the shards move to 16 vCPU, and four count partitions
(27 tests each) replace three hash ones (41/40/27).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:23:41 -04:00
bherbruck a6354dfec5 CI: GPU path tests in three shards of their own; Windows in the queue only when a group touches it (#7066)
The 108 gpu path:: tests run one at a time (gpu-path test group) and took
about 36 of Native Linux tests' 45 minutes in a merge-queue run. They now
run in a GPU path tests job, three hash partitions on 8 vCPU, beside the
other tests; Native Linux tests leaves exactly them out. A `gpu` output of
Select checks drives both the job's `if` and the `ci` check.

The Windows jobs now run in a merge group only when its diff reaches what
they exercise (the pull request patterns and the Cargo.lock graphics-stack
check), not on pushes to main, and still nightly and by hand.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 08:22:53 -04:00
sascha2101 8108646e2c Docs: a sketch family's shortcut starts the variant used last (Refs #6630) (#6817)
Since #4308, L, R and C start their family in the variant used last, as
the toolbar button does, so L can start a Midpoint Line. The shortcuts
table still read "L | Line". Say so under the table and in the
sketching guide's Geometry section, with the preference that steps
through the variants.


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

Co-authored-by: sascha2101 <221609842+sascha2101@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:39:51 +00:00
Victor Santos Oliveira b3cbbe7bd5 CLI: reject incomplete and unsupported run/eval options (#6918) (#6919)
* claim: #6918 validate run and eval options

* fix(cli): reject invalid run and eval options (#6918)
2026-10-26 10:38:56 +00:00
Mishung ae2d2145b2 CONTRIBUTING: the desktop app runs on macOS too (#4535)
The README and docs/downloads.md ship a universal macOS disk image, but the
contributing guide listed only Windows and Linux.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:50 +00:00
Basil a800a40099 Fix Snap smoke startup race before viewport readiness (#5346) 2026-10-26 10:24:40 +00:00
Badger B dc3061a039 Docs: on Windows, give claude mcp add the CLI's full path (#5895) (#5896)
* Docs: on Windows, give claude mcp add the CLI's full path (#5895)

The Windows installer puts solidcraft-cli.exe in C:\Program Files\SolidCraft but not on PATH,
so the README's and docs/mcp.md's `claude mcp add solidcraft -- solidcraft-cli mcp` registers a
server that can't start there. Say so next to the command and give the full-path form.

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

* Docs: word the Windows full-path note so it holds once the installer sets PATH (#5895)

#5898 makes the MSI add its folder to PATH. Say "if a terminal doesn't find solidcraft-cli
(older Windows installers don't put it on PATH)" instead of stating that the installer never
does, so the note stays true whichever change lands first.

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

* Docs: the Windows CLI path is the default, not the only place (#5895)

The MSI installs to ProgramFiles6432Folder and lets people choose the folder
(WixUI_InstallDir), so C:\Program Files\SolidCraft\ is only the x64 installer's default; the
x86 MSI on 64-bit Windows lands in C:\Program Files (x86)\SolidCraft\. Say "by default" in
the README and docs/mcp.md (review on #5896).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:34 +00:00
Thosch 102314dbb2 Docs: blurry interface on Linux with display scaling, and how to fix it (#5979) (#6195)
SolidCraft runs through XWayland whenever an X display is available. A
compositor that doesn't let X11 apps scale themselves draws the window at
100 % and stretches it. docs/nix.md said so only for fractional scaling,
but GNOME 47 (RHEL and Rocky Linux 10) does it at any scale: the report
shows 1.05 physical pixels per point at 200 %.

The troubleshooting page gets a section with the cause, the Window system
preference, SOLIDCRAFT_WAYLAND=1 for 0.21.0 and earlier, and the file drop
trade-off; docs/nix.md links to it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:24:30 +00:00
350 changed files with 23281 additions and 2207 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml),
# except the GPU path tracer's tests (the gpu-path group below), which run in four shards of their own.
[profile.ci]
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
@@ -72,6 +73,7 @@ priority = 100
# Each nextest test has its own process/device. Concurrent cold lavapipe path
# compiles contend for CPU and can exhaust the 600 s pipeline setup budget.
# Serialize this module per CI shard; other tests retain their normal parallelism.
# CI runs these in their own job (GPU path tests in ci.yml), split into count partitions.
[test-groups.gpu-path]
max-threads = 1
+158 -22
View File
@@ -9,12 +9,14 @@
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
# no native job runs for them, on pull requests or on main.
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
# main saves the build caches; pull requests restore them. sccache is on a Blacksmith sticky disk that
# only main commits.
# checks (no archive to upload and download). The GPU path tracer's tests run one at a time
# (.config/nextest.toml), so they have a job of their own in four shards (GPU path tests). A few
# heavy tests (see .config/nextest.toml) are skipped on pull requests and run on pushes to main and
# nightly. The reference tests and the doc tests run in the merge queue, on main and nightly, not
# on pull requests (the fast lane, in Select checks); a break there is fixed forward on main. The
# Windows jobs (D3D12 shaders and app startup) run nightly, and in the merge queue only for a group
# that changes what they exercise. Only main saves the build caches; pull requests restore them.
# sccache is on a Blacksmith sticky disk that only main commits.
# A push to main runs only the repository checks when a passing merge-queue run tested that very
# commit, or when nothing a build reads changed since the last push to main whose run passed.
# Otherwise it runs everything.
@@ -30,7 +32,7 @@
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
# per job: 32 vCPU for the tests, 16 for Clippy, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
# per job: 32 vCPU for the tests, 16 for Clippy, the GPU path tests, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
@@ -103,6 +105,9 @@ jobs:
kernel: ${{ steps.paths.outputs.kernel }}
goldens: ${{ steps.paths.outputs.goldens }}
windows: ${{ steps.paths.outputs.windows }}
# The GPU path tests run: native, and the whole workspace or solidcraft-gpu selected. The
# job's `if` and the `ci` job's check both read this, so they cannot disagree.
gpu: ${{ steps.paths.outputs.gpu }}
full: ${{ steps.paths.outputs.full }}
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
quick: ${{ steps.paths.outputs.quick }}
@@ -122,6 +127,8 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_SHA: ${{ github.sha }}
GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
GROUP_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
@@ -144,6 +151,23 @@ jobs:
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
}
# Whether the NUL-separated paths in $1 (the diff $2) reach what the Windows D3D12 jobs
# exercise: the GPU crate and its shaders, the app and its startup, the viewport's wgpu
# glue, the vendored egui-wgpu, their scripts, or a lockfile change that moves the
# graphics stack (wgpu, naga, egui, winit, windows). Pull requests and merge groups share it.
windows_paths() {
while IFS= read -r -d '' path; do
case "$path" in
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
esac
done < "$1"
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$1" \
&& git diff "$2" -- Cargo.lock \
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
windows=true
fi
}
if [ "$EVENT_NAME" = push ]; then
# The queue lands the very commit its merge_group run tested. If that run passed,
# testing the push again proves nothing new; otherwise (a merge that skipped the
@@ -164,13 +188,25 @@ jobs:
# push before may never have run: a newer push cancels a waiting one).
echo "$tested..$PUSH_SHA changes no code: repository checks only."
else
# Code changed (or the push can't be compared): main runs everything.
# Code changed (or the push can't be compared): main runs everything but Windows.
# The merge queue ran Windows for the changes that reach it, and the nightly runs it
# for everything else (it passed 129 of 129 non-PR runs before this).
full=true
packages=
native=true
fi
elif [ "$EVENT_NAME" = merge_group ]; then
native=true
# Windows (about 16 runner-minutes) only for a group that changes what it exercises;
# the nightly runs it whatever changed. A group that can't be compared runs it.
if git diff --no-renames --name-only -z "$GROUP_BASE_SHA...$GROUP_HEAD_SHA" > "$RUNNER_TEMP/ci-paths"; then
windows_paths "$RUNNER_TEMP/ci-paths" "$GROUP_BASE_SHA...$GROUP_HEAD_SHA"
else
windows=true
fi
echo "windows=$windows for $GROUP_BASE_SHA...$GROUP_HEAD_SHA"
elif [ "$EVENT_NAME" != pull_request ]; then
# The nightly run and Run workflow: everything, Windows included.
native=true
windows=true
else
@@ -185,19 +221,9 @@ jobs:
case "$path" in
crates/kernel/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
esac
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
case "$path" in
crates/gpu/*|apps/solidcraft/*|crates/ui-egui/src/gpu.rs|vendor/egui-wgpu/*) windows=true ;;
tools/windows-*.ps1|.github/workflows/ci.yml|rust-toolchain*|.cargo/*) windows=true ;;
esac
done < "$RUNNER_TEMP/ci-paths"
# A lockfile change that moves the graphics stack (wgpu, naga, egui, winit, windows).
if [ "$windows" = false ] && grep -qxz 'Cargo.lock' "$RUNNER_TEMP/ci-paths" \
&& git diff "$BASE_SHA...$HEAD_SHA" -- Cargo.lock \
| grep -Eq '^[ +-]name = "(wgpu[a-z_-]*|naga|egui[a-z_-]*|eframe|winit|windows[a-z_-]*|d3d12)"'; then
windows=true
fi
# Computed as for a merge group, then turned off below (the fast lane).
windows_paths "$RUNNER_TEMP/ci-paths" "$BASE_SHA...$HEAD_SHA"
# Pull requests build and test the touched packages and their dependents; nothing
# selected means no code changed.
select_packages "$RUNNER_TEMP/ci-paths"
@@ -242,8 +268,16 @@ jobs:
goldens=false
fi
fi
# The GPU path tests (their own job) run with the other tests, when solidcraft-gpu is
# among the packages they test.
gpu=false
if [ "$native" = true ]; then
case " $packages " in *" solidcraft-gpu "*) gpu=true ;; esac
if [ "$full" = true ]; then gpu=true; fi
fi
{
echo "native=$native"
echo "gpu=$gpu"
echo "kernel=$kernel"
echo "goldens=$goldens"
echo "windows=$windows"
@@ -286,6 +320,10 @@ jobs:
cargo test -p xtask ui_containers --locked
- name: Sorted lists
run: cargo xtask lists
- name: Agent router and skills cite real files
run: |
cargo xtask skills
cargo test -p xtask skills --locked
- name: Design format pending changes
run: bash scripts/test-format-pending.sh
- name: Web app offline use and updates (headless Chrome)
@@ -487,8 +525,10 @@ jobs:
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
status=0
# The GPU path tracer's tests run in their own job (GPU path tests): one at a time, they
# took about 36 of this job's 45 minutes.
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
--ignore-default-filter -E "$base" || status=1
--ignore-default-filter -E "($base) & not (package(=solidcraft-gpu) & test(/^path::/))" || status=1
if [ "$status" != 0 ]; then
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
@@ -508,6 +548,96 @@ jobs:
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
gpu-path-tests:
name: GPU path tests (${{ matrix.shard }}/4)
# The GPU path tracer's tests (about 108) compile their pipelines on lavapipe, one test at a
# time (the gpu-path test group in .config/nextest.toml keeps them within the 600 s pipeline
# setup budget). Serial, they took about 36 minutes, the longest chain in Native Linux tests;
# here four shards run beside it. Their filter is the one Native Linux tests leaves out, so
# every test runs exactly once.
# 16 vCPU, not 8: lavapipe compiles on every core, and on 8 vCPU one test went past the 600 s
# budget (665 s, against 204 s for the same test in another run) and a shard took 35 minutes.
needs: changes
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.gpu == 'true' }}
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
# As in Native Linux tests: lavapipe draws, and a test without an adapter fails.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
# lavapipe's own shader cache (below).
MESA_SHADER_CACHE_DIR: /home/runner/.cache/mesa-gpu
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install a software Vulkan driver (lavapipe)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
# The shards build the same thing, so only the first one commits it (and saves the cache).
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-gpu
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 && 'on-change' || 'false' }}
# The first test in a shard to build the path pipeline compiles it cold on lavapipe (335 to
# 665 s, against the 600 s setup budget); every later one reads Mesa's disk cache (about
# 30 s). Keeping that cache from main's last run lets the first test read it too, until the
# shaders change. Same sticky disk rules as the compiler cache.
- name: Mount lavapipe's shader cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-mesa-gpu
path: /home/runner/.cache/mesa-gpu
commit: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-gpu
save-if: ${{ github.ref == 'refs/heads/main' && matrix.shard == 1 }}
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- name: Run one shard of the GPU path tests
env:
SHARD: ${{ matrix.shard }}
run: |
# None of these is in the default filter (the heavy tests), so pull requests, the merge
# queue, main and nightly all run every one of them, as before. `count` deals the tests
# out in turn (27 each); `hash` split them 41/40/27 across three shards.
cargo nextest run -p solidcraft-gpu --cargo-profile ci --locked --profile ci --no-tests=pass \
--ignore-default-filter -E 'test(/^path::/)' --partition "count:$SHARD/4"
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-gpu-${{ matrix.shard }}
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
action-matrix:
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
@@ -769,7 +899,7 @@ jobs:
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
name: ci
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
runs-on: blacksmith-2vcpu-ubuntu-2404
permissions:
@@ -817,6 +947,8 @@ jobs:
BUILD: ${{ needs.build-tests.result }}
EVENT_NAME: ${{ github.event_name }}
TESTS: ${{ needs.tests.result }}
GPU_RUN: ${{ needs.changes.outputs.gpu }}
GPU: ${{ needs.gpu-path-tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
@@ -836,6 +968,10 @@ jobs:
fi
test "$TESTS" = success
fi
# The same output decides whether the GPU path tests run (their job's `if`).
if [ "$GPU_RUN" = true ]; then
test "$GPU" = success
fi
if [ "$DOCTESTS" = true ]; then
test "$DOCS" = success
fi
+42
View File
@@ -0,0 +1,42 @@
# Nightly import checks on real-world STEP files (#1397): the external corpus in
# tests/external-corpus.toml is downloaded (hash-verified), imported headless, and checked
# against tolerance-based expectations — body counts, total volume and area, time and memory
# budgets. It is not part of `cargo xtask ci`: it downloads from the internet.
#
# Import results should improve over time (healing, edge merging, meshing). When a check fails
# because the numbers moved for the better (or the source changed upstream), re-record with
# `cargo xtask external-corpus --bless "why"` in a pull request, never by editing numbers.
name: External corpus
on:
schedule:
- cron: '40 3 * * *'
workflow_dispatch:
inputs:
only:
description: "Run only this corpus entry name (empty = every entry)"
type: string
default: ""
permissions:
contents: read
jobs:
corpus:
name: Import the external STEP corpus
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- name: Build the CLI
run: cargo build --profile ci -p solidcraft-cli
- name: Import and check every entry
env:
ONLY: ${{ inputs.only }}
run: |
if [ -z "$ONLY" ]; then
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli
else
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli --only "$ONLY"
fi
+1 -1
View File
@@ -16,7 +16,7 @@ on:
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: read # to list a PR's changed paths
pull-requests: write # to list a PR's changed paths and add its label
discussions: write
concurrency:
+125 -1
View File
@@ -5,7 +5,9 @@
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
# A version with a suffix (0.2.0-rc.1) is a pre-release.
# The macOS app is signed and notarized when the APPLE_* secrets exist, nothing else is signed yet.
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
# (see the windows job). Nothing else is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
@@ -18,6 +20,8 @@
# releases/latest/download/<name> links in the README keep working across releases.
#
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
# A dry run from any branch also test-signs the Windows files when SIGNPATH_API_TOKEN exists:
# gh workflow run release.yml --ref <branch> -f draft=false [-f signpath_signing_policy=test-signing]
name: Release
on:
@@ -32,6 +36,10 @@ on:
description: "Create/update a draft (release branch only; off: build artifacts only)"
type: boolean
default: false
signpath_signing_policy:
description: "SignPath signing policy for the Windows files (empty: the repository variable, else test-signing)"
type: string
default: ""
permissions:
contents: read
@@ -378,7 +386,14 @@ jobs:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
if: needs.version.outputs.build == 'true'
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
# only signs builds whose jobs all ran on GitHub-hosted runners.
runs-on: windows-latest
# actions: read lets the SignPath action read this job's details and fetch the uploaded
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix:
@@ -389,6 +404,16 @@ jobs:
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
# Authenticode signing through SignPath Foundation (docs/releasing.md, "Windows signing").
# Only when the API token secret exists: forks and repositories without it build unsigned,
# exactly as before. `if:` can't read secrets, so the check goes through this variable.
SIGN_WINDOWS: ${{ secrets.SIGNPATH_API_TOKEN != '' }}
# The one switch between the self-signed test certificate (test-signing) and the real one
# (release-signing): a dispatch input, else the SIGNPATH_SIGNING_POLICY repository variable,
# else test-signing.
SIGNPATH_SIGNING_POLICY: ${{ inputs.signpath_signing_policy || vars.SIGNPATH_SIGNING_POLICY || 'test-signing' }}
SIGNPATH_ORGANIZATION_ID: fa603ff4-c702-404b-8588-a524371c7a4e
SIGNPATH_PROJECT_SLUG: solidcraft
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -407,10 +432,109 @@ jobs:
dotnet tool install --global wix --version 5.0.2
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (msi, portable zip)
if: env.SIGN_WINDOWS != 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
# ---- signed path ---------------------------------------------------------------------------
# SignPath's GitHub integration (docs.signpath.io/trusted-build-systems/github): the files go
# up as a workflow artifact, SignPath fetches that artifact itself (so it can verify it came
# from this workflow run), signs it, and the action downloads the signed copy. GitHub stores
# artifacts as zips, so packaging/windows/signpath-artifact-configuration.xml (pasted into the
# SignPath project) describes a zip holding the files at its top level.
# Two rounds: the .exe files first, so the MSI, the portable zip and the plain .exe all carry
# signed binaries; then the MSI built from them.
- name: Build and stage the binaries for signing
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
- name: Upload the unsigned binaries
id: unsigned-exes
if: env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signpath-unsigned-exes-windows-${{ matrix.arch }}
path: target/windows-package/${{ matrix.arch }}/*.exe
if-no-files-found: error
retention-days: 3
- name: Sign the binaries (SignPath, ${{ env.SIGNPATH_SIGNING_POLICY }})
if: env.SIGN_WINDOWS == 'true'
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
github-artifact-id: ${{ steps.unsigned-exes.outputs.artifact-id }}
wait-for-completion: true
# release-signing requests wait for a person to approve them in SignPath; give that an
# hour instead of the default 10 minutes. test-signing requests finish in seconds.
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: target/windows-package/signed-exes-${{ matrix.arch }}
- name: Put the signed binaries in place
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$stage = "target/windows-package/${{ matrix.arch }}"
foreach ($exe in 'solidcraft.exe', 'solidcraft-cli.exe', 'solvecraft-cli.exe') {
$signed = "target/windows-package/signed-exes-${{ matrix.arch }}/$exe"
# The artifact configuration matches each file optionally, so check that every file
# actually came back signed rather than trusting the request's success.
$sig = Get-AuthenticodeSignature $signed
if (-not $sig.SignerCertificate) { throw "$exe came back from SignPath without a signature ($($sig.Status))" }
# The test certificate is self-signed, so Status is not Valid until release-signing.
Write-Output "$exe signed by $($sig.SignerCertificate.Subject) (status: $($sig.Status))"
Copy-Item -Force $signed "$stage/$exe"
}
- name: Package the signed binaries (msi, portable zip)
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
- name: Upload the unsigned MSI
id: unsigned-msi
if: env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signpath-unsigned-msi-windows-${{ matrix.arch }}
path: dist/release/*.msi
if-no-files-found: error
retention-days: 3
- name: Sign the MSI (SignPath, ${{ env.SIGNPATH_SIGNING_POLICY }})
if: env.SIGN_WINDOWS == 'true'
uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ env.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ env.SIGNPATH_SIGNING_POLICY }}
github-artifact-id: ${{ steps.unsigned-msi.outputs.artifact-id }}
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 3600
output-artifact-directory: target/windows-package/signed-msi-${{ matrix.arch }}
- name: Put the signed MSI in place
if: env.SIGN_WINDOWS == 'true'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = Get-Item "dist/release/*.msi"
$signed = "target/windows-package/signed-msi-${{ matrix.arch }}/$($msi.Name)"
$sig = Get-AuthenticodeSignature $signed
if (-not $sig.SignerCertificate) { throw "$($msi.Name) came back from SignPath without a signature ($($sig.Status))" }
Write-Output "$($msi.Name) signed by $($sig.SignerCertificate.Subject) (status: $($sig.Status))"
Copy-Item -Force $signed $msi.FullName
"Windows ${{ matrix.arch }}: Authenticode-signed through SignPath (policy $env:SIGNPATH_SIGNING_POLICY, $($sig.SignerCertificate.Subject))." >> $env:GITHUB_STEP_SUMMARY
- name: Note the unsigned build
if: env.SIGN_WINDOWS != 'true'
shell: pwsh
run: '"Windows ${{ matrix.arch }}: unsigned (no SIGNPATH_API_TOKEN secret)." >> $env:GITHUB_STEP_SUMMARY'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-windows-${{ matrix.arch }}
+4
View File
@@ -11,3 +11,7 @@ __pycache__/
# cargo-mutants output (docs/mutation-testing.md)
mutants.out*/
# external corpus downloads (cargo xtask external-corpus, #1397): fetched at test time,
# hash-pinned in tests/external-corpus.toml; never commit case data
/corpus/
+44 -227
View File
@@ -1,231 +1,48 @@
# SolidCraft — instructions for agents
# SolidCraft: instructions for agents
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
SolidCraft is a clean-room, pure-Rust parametric 3D CAD app (sketch solver, feature timeline, B-rep kernel, an egui UI that agents drive). This file is a router: the hard rules, then the skill to read for your task. Humans: CONTRIBUTING.md.
## Product name
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
`/solidcraft/`; do not add permanent old-name Pages aliases.
## Start here
Work: ROADMAP.md, "Where help is wanted most" in CONTRIBUTING.md, issues labelled `help wanted`, `good first issue`, `needs-triage`; architecture in `docs/architecture.md`. Act when the code, docs and rules settle a question; ask only when it changes what users see. Local `plan/` notes are optional.
## Start every session here
1. What to work on: [ROADMAP.md](ROADMAP.md) (status and milestones), "Where help is wanted most"
in CONTRIBUTING.md, and the open issues labelled `help wanted`, `good first issue` and
`needs-triage`. Check what is already open first (below).
2. How the code fits together: [docs/architecture.md](docs/architecture.md) (the layer table) and
"How the code is laid out" in CONTRIBUTING.md. Kernel work is paused ("Kernel work is paused"
in CONTRIBUTING.md).
3. What we may depend on: the licence rules under Non-negotiables below, `deny.toml`, and
`cargo xtask licences --check`. Only `crates/kernel` may use `truck`.
4. Work on your own: when the code, the docs and these rules settle a question, act on it. Ask on
the issue or the PR only when the answer changes what users see or what the change covers.
## Hard rules (what enforces each)
- Independent: never copy another product's code, icons, text or templates; never drive or take data from commercial CAD. `skills/solidcraft-ground-rules/SKILL.md`.
- No GPL/LGPL/AGPL code, not even reading it (OpenCASCADE, FreeCAD, SolveSpace, CadQuery, LibreCAD, QCAD): `cargo xtask licences --check`.
- Every asset is original or openly licensed with a row in ATTRIBUTION.md; read `assets/AGENTS.md` before icon work: `cargo xtask assets`, `cargo xtask icons`.
- Never crash: no unwrap/expect/panic/unsafe in non-test code (unsafe allowlist in crash-safety), errors are `Result`, input is hostile: clippy `-D warnings`, `hostile_params_never_panic`.
- 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`.
- 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`.
- Sorted lists (message catalogs, `mod` lines, languages) stay sorted: `cargo xtask lists`. Never edit generated files (`docs/features.md`, `THIRD-PARTY-LICENSES.txt`) in a PR.
- A design-format change is ONE new file in `crates/doc/src/format/pending/`: `scripts/test-format-pending.sh`.
- A command has input and output schemas (will become required; `schema/unconverted.txt` only shrinks): `skills/solidcraft-commands/SKILL.md`.
- New UI strings go in `assets/locales/pending/<slug>.json`, ATTRIBUTION rows in `attribution/pending/<slug>.md`: `cargo xtask lists`.
- A changed default keeps the old behaviour behind a preference and fills the PR's `Changes a default:` line, and no second PR for an issue a lower-numbered one closes: `pr-checks.yml`.
- A regression test goes in the area's existing test file, never a per-bug file (#3226).
- Look at open PRs first, claim with a draft PR (lowest number wins), one topic per PR, never force-push `main`, no pushes just to catch up with `main`: `skills/solidcraft-contributing/SKILL.md`.
- Show observable changes with real screenshots or a recording, captured yourself.
- Run `cargo xtask ci` before every commit, without switching branches while it runs. `cargo xtask skills` keeps this file and `skills/` honest.
Maintainers may keep local notes in `plan/` (gitignored); nothing in this file depends on them.
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The exceptions, in the app crate and in
`crates/threedx-client` (which deny it instead), have no safe API: the macOS-only
`crates/threedx-client/src/macos.rs` (loads the 3Dconnexion driver's client framework at run time
and calls through its function pointers), two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers), the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations, and gives its class the icon the taskbar falls back
on) and the Windows-only
`apps/solidcraft/src/space_mouse/windows_hid.rs` (asks Plug and Play which HID interfaces are 3D
mice, so no other device is sent anything).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
panic restores the design and reports `EngineError::Internal`.
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- **Where tests go:** a regression test goes into the existing test file for its area (booleans, blends,
shell, sweep, STEP, naming…), never a new file named after the bug (#3226).
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
general CAD conventions and user feedback. Reference tests use analytic values and open-source
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Command icons are the original SVG masters in
`assets/icons/ui/design/svg/`, compiled through `crates/ui-egui/src/icon_svg.rs`.
**Before any icon, icon assignment or media edit, read `assets/AGENTS.md`,
`assets/icons/ui/AGENTS.md` and `assets/icons/ui/STYLE_GUIDE.md`.** Use the canonical
bases and named finished references there; never invent a parallel icon style. Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
`solid.extrude` or `sketch.rectangle.two_point`, never another product's command id; label;
toolbar tab and panel; icon; shortcut; params doc; `enabled`; JSON `run`) + input and output
schemas (a contract and a reply type in `crates/engine/src/schema/`, registered with
`checked!`, `.contract` and `.output`; a command should have them, and a test warns
without them (it will become required); `schema/unconverted.txt` only shrinks; how: docs/book/src/dev/schemas.md) + tests. The toolbar,
dialogs, sketch tools, palette, scripts, CLI and control channel all reach the same commands.
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form; only toolbar
clicks (`ui.start`) start interactive tools and dialogs, which then run commands.
- **Layering** is enforced by `cargo xtask layers`: L0 `geom` → L1 `sketch`, `kernel`, `render`
→ L2 `doc` → L3 `io` → L4 `engine` → L5 `ui-egui` → apps. Nothing below L5 depends on
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **One outer UI container**: all panels, floating sheets, menus, overlays and platform UI
windows use `ui_kit::Container` (directly or through `Panel`, `Window`, `Confirm`, `Menu`).
Declare the semantic `Surface`; workspace panels use its shared layout policy and chrome.
System/report windows use `ui_kit::Window`; its low-level host builder is kit-private.
Do not construct raw egui hosts, outer popup/window frames or shadows in feature modules.
`cargo xtask ui-containers` recursively checks source/examples in all current and future crates
and apps. It runs in the fast repository CI job and `cargo xtask ci`, with no caller allowances.
See `docs/ui-container-audit.md` for the inventory and legitimate content/native exceptions.
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
camera input (a test rejects it).
- **Application code is Rust only.** Units are millimetres and radians internally; Z is up.
Local asset authoring, lint and review tools use Python 3.11+ as documented in
`assets/AGENTS.md`; Python is not an application runtime dependency. The application exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds. The gate reads the working tree,
so do not switch branches while it runs: it compiles the other branch's code and fails on a
change that is not in the PR. Run it in the background and wait for its log.
- **Publishing**: the repository is github.com/tangency-labs/solidcraft. Changes land through
**pull requests** and the **merge queue**: push a branch and open a PR. The heavy CI jobs start
when a maintainer adds `approved-for-ci` after reviewing the code. Approved PRs go into the queue
with `gh pr merge N -R tangency-labs/solidcraft --squash --auto`; the queue runs the full suite
on each group before it lands on `main`. Keep each PR to one topic and one issue, and never
force-push `main`. Never commit `plan/`, build dirs (`target*/`) or generated reference data;
check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Showing and following through on changes
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
issues and PRs must include actual descriptive screenshots or a short recording from a
small reproduction. This includes kernel, geometry, assembly and backend changes whose
effects can be shown in the app. Coding agents should run the app and capture this evidence
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
or result, preferably before and after, with brief reproduction steps, expected/actual
behavior and annotations that point out the relevant detail.
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
- Use a small public-safe example and check for private designs, paths, account details or
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
recording; do not commit generated screenshots or videos just to provide review evidence.
- Throughout the work and before handoff, check the relevant PR's merge status. Don't push to
an open PR only to catch up with `main` (no merge of `main`, no Update branch, no rebase for
that alone): every push restarts the whole CI run, and `main`'s own run catches what lands in
between, to be fixed forward. When you push a change anyway, or GitHub reports a conflict,
rebase onto the latest `main` (push your own branch with `--force-with-lease`); never merge
`main` into a PR. Resolve conflicts without discarding others' work; never force-push `main`.
Don't re-run CI on an unchanged commit: a real failure fails again. Re-run only a job that
runner trouble cancelled, with "Re-run failed jobs". Report the PR state you checked and any
conflicts resolved. If a PR remains open, keep monitoring it while the task is active and
make its pending status clear at handoff; do not claim it merged without checking.
## Before you start: look at what is already open
- Work with an authenticated GitHub CLI (`gh auth status`) and `git`; the checks below need both.
- Pull the whole list, not one search: `gh pr list -R tangency-labs/solidcraft --state open --limit 500`
(drafts included) and `gh issue list -R tangency-labs/solidcraft --state open --limit 500`. Look for
work on the same issue, the same feature under another number, and PRs that change the files
you are about to change (`gh pr list ... --json number,title,files`).
- If someone already has it, comment on their PR or issue instead of opening a second one. Two
PRs for one change can't both land, and one of them is wasted work and CI.
- Keep the `Changes a default:` line in the PR description filled in, and don't open a second PR for
an issue a lower-numbered open PR already closes; `pr-checks.yml` fails both (CONTRIBUTING.md, "Sending a change").
## Claim = a draft PR
- Before working on issue #N, check nobody has it: `gh pr list -R tangency-labs/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
labels the issue `in progress` (or says on your PR who holds it). Reference `#N` in the PR title
so the bot can find it. Don't claim by editing files; comment `/claim` on the issue only to stake
a claim before you have a branch.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
and wait while it is over 24; build with `nice`.
## Design file format
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
Cargo.lock, pass the quality gates, advance `release` without force-pushing, and verify the
resulting draft and its artifacts. When shipping is authorized, publish that verified draft
and confirm its tag and downstream deployment. Do not auto-release every push to `main`, infer
version bumps solely from commit prefixes, overwrite published versions, or push release tags
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
request on a connection carries the launch's token as `"token"`: read it from
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`docs/roadmap-detail.md` (committed) tracks status, milestones and estimates; `ROADMAP.md` is the short user-facing roadmap. Update it whenever a
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
workflow; never regenerate them in a PR.
## Lists many PRs add to
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
then by id, generated files left to `main`, and a design-format change as one new file.
New UI strings go in a new `assets/locales/pending/<slug>.json` and new ATTRIBUTION.md rows in a new
`attribution/pending/<slug>.md`, never into the catalogs or the table (see the README in each folder).
## Working on X: read the skill first
| Working on | Read |
|---|---|
| A PR, a claim, review, CI or evidence | `skills/solidcraft-contributing/SKILL.md` |
| Licences, assets, clean-room, the name | `skills/solidcraft-ground-rules/SKILL.md` |
| Non-test Rust, hostile input, a crash report | `skills/solidcraft-crash-safety/SKILL.md` |
| A command, a crate boundary | `skills/solidcraft-commands/SKILL.md` |
| A dialog, panel, camera, icon, colour | `skills/solidcraft-ui/SKILL.md` |
| Labels, messages, translations | `skills/solidcraft-ui-text/SKILL.md` |
| Saving something new in a design, import, export | `skills/solidcraft-file-formats/SKILL.md` |
| Preferences, a changed default | `skills/solidcraft-preferences/SKILL.md` |
| Writing or running tests | `skills/solidcraft-tests/SKILL.md` |
| Slow work, the window thread, wasm | `skills/solidcraft-performance/SKILL.md` |
| Releases, installers, packages | `skills/solidcraft-packaging/SKILL.md` |
| Running the app, control channel, MCP tools | `skills/solidcraft-agent-tools/SKILL.md` |
| Building a part over MCP | `skills/solidcraft-modeling/SKILL.md` |
| Changing an existing design | `skills/solidcraft-editing/SKILL.md` |
| A bug report, a crash, a wrong result | `skills/solidcraft-reporting/SKILL.md` |
| Checking, porting, designs in git | `skills/solidcraft-checking/SKILL.md` |
| Assemblies, joints, motion | `skills/solidcraft-mechanisms/SKILL.md` |
>>>>>>> 4a3e0772a (Agents: AGENTS.md becomes a router to focused skills, with a staleness check (Refs #6439))
+8
View File
@@ -174,6 +174,10 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.mime.xml | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/macos/dmg/background.tiff | SolidCraft contributors | original disk image window background (blueprint sheet: grid, double border, circles, centre lines, leader arrow, title block) drawn by assets/platforms/macos/dmg/make_background.py in IBM Plex Sans/Mono; 1x and 2x representations | MIT OR Apache-2.0 |
| assets/platforms/macos/dmg/make_background.py | SolidCraft contributors | authoring script that draws the disk image background (Pillow); not part of any build | MIT OR Apache-2.0 |
| assets/platforms/macos/dmg/layout.json | SolidCraft contributors | disk image window size, icon size and icon positions shared by the background script and settings.py | MIT OR Apache-2.0 |
| assets/platforms/macos/dmg/settings.py | SolidCraft contributors | dmgbuild settings (MIT, https://github.com/dmgbuild/dmgbuild) for the SolidCraft disk image; original settings, no dmgbuild code copied | MIT OR Apache-2.0 |
| assets/platforms/macos/Info.plist.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/macos/README.txt | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
@@ -519,6 +523,10 @@ Editable drawing code, native projects and shipped exports are collected under `
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
| docs/sponsors/blacksmith-ci-powered.png | Blacksmith (blacksmith.sh) | "CI powered by Blacksmith" sponsor banner supplied by Blacksmith for the sponsorship credit; used only to credit the sponsor, not part of the application | © Blacksmith, used with permission |
| assets/icons/ui/design/svg/model/path_flange.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| docs/book/src/generated/hem.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/hem.json`; gitignored), original sheet metal demonstration | MIT OR Apache-2.0 |
| docs/book/scripts/hem.json | SolidCraft contributors | original parametric recipe for an 80 × 50 mm sheet with an 8 mm returned edge, authored for the command guide | MIT OR Apache-2.0 |
| assets/styles/ui/themes/clay.json | SolidCraft contributors | original SolidCraft theme file (Clay, light): warm paper palette chosen for SolidCraft | MIT OR Apache-2.0 |
| assets/styles/ui/themes/clay-dark.json | SolidCraft contributors | original SolidCraft theme file (Clay Dark): warm charcoal palette chosen for SolidCraft | MIT OR Apache-2.0 |
## Code adapted from sibling projects
+8 -4
View File
@@ -1,8 +1,10 @@
# Contributing to SolidCraft
Agents: start from the router in [AGENTS.md](AGENTS.md), which points to a short skill in [`skills/`](skills/README.md) for each kind of work. This page stays the full human guide.
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
You sketch on a plane, constrain the sketch, turn profiles into solids, and
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, macOS, Linux), in
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
model with.
@@ -192,9 +194,11 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
shared bound on top-level test/side commands for `cargo xtask test` and `cargo xtask ci`.
For example, `SOLIDCRAFT_TEST_JOBS=2` allows two commands at once; the sequential wasm/doc
worker consumes one of those slots. Every binary and side check still runs after failures.
Without the variable the existing concurrency is unchanged. `RUST_TEST_THREADS`,
`ACTION_MATRIX_JOBS` and Cargo's build-job setting remain independent caller overrides;
this limit does not choose their values or change test filters, timeouts or baselines.
Without the variable the existing concurrency is unchanged. With it set, each test binary
inherits `RUST_TEST_THREADS` and `ACTION_MATRIX_JOBS` of `max(1, cpus / jobs)` — the machine's
parallelism shared between the binaries in flight — unless you set either one explicitly;
explicit caller overrides always win. Cargo's build-job setting remains an independent caller
override; this limit does not change test filters, timeouts or baselines.
Tests run beside each other on a busy machine, so a test must not depend on wall-clock timing.
**Ready pull requests test what they touch.** CI maps changed files to workspace packages and
Generated
+21 -17
View File
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4240,11 +4240,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.23.1"
version = "0.24.0"
[[package]]
name = "solidcraft-cli"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"image",
"log",
@@ -4256,7 +4256,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"proc-macro2",
"quote",
@@ -4265,7 +4265,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"log",
"serde",
@@ -4282,7 +4282,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"log",
"schemars",
@@ -4302,7 +4302,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
@@ -4311,7 +4311,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"image",
"log",
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4355,7 +4355,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"log",
"proptest",
@@ -4375,7 +4375,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4384,7 +4384,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"image",
"moxcms",
@@ -4398,7 +4398,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
@@ -4409,14 +4409,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"arboard",
"egui",
@@ -4445,12 +4445,13 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"percent-encoding",
"serde_json",
"solidcraft-engine",
"solidcraft-ui-egui",
@@ -5983,12 +5984,15 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.23.1"
version = "0.24.0"
dependencies = [
"proc-macro2",
"serde",
"serde_json",
"sha2 0.10.9",
"syn 2.0.119",
"toml",
"zip",
]
[[package]]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.23.1"
version = "0.24.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+4 -2
View File
@@ -96,8 +96,10 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server. Headless it
runs its own design session, or with `--connect` it drives the running app so you can watch.
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Tools, access rules and an
example session: [docs/mcp.md](docs/mcp.md).
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
`"C:\Program Files\SolidCraft\solidcraft-cli.exe"`). Tools, access rules and an example session:
[docs/mcp.md](docs/mcp.md).
## Scripts
+4 -3
View File
@@ -262,8 +262,7 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
if solidcraft_engine::io::is_design_container(&bytes) {
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::new(doc);
session.path = Some(path.to_owned());
let mut session = Session::opened_at(doc, None, Some(path));
session.param_check = check;
return Ok(session);
}
@@ -340,6 +339,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
fn cmd_run(args: &[String]) -> Result<(), String> {
install_renderer();
let json_out = json_out(args)?;
known_args("run", args, &["--in", "--out", "--save", "--json-out"], &["--quiet", "--results", "--timings", "--profile", "--allow-errors"], true)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
@@ -444,6 +444,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
known_args("eval", args, &["--in", "--json-out"], &[], true)?;
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(usage)?;
let mut s = load(path)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
@@ -843,7 +844,7 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
return Ok(());
}
let t = std::time::Instant::now();
let s = Session::opened(doc, saved.as_ref());
let s = Session::opened_at(doc, saved.as_ref(), (!step).then_some(path.as_str()));
drop(read);
// Taken from the file: nothing evaluated (SOLIDCRAFT_NO_MODEL_CACHE evaluates anyway), or
// only the features whose state a thinned file left out.
+39 -2
View File
@@ -395,8 +395,10 @@ fn actions(s: &Session) -> Vec<Action> {
let r = round3((shortest * 0.1).max(0.01));
let mut add = |action, act| out.push(Action { entity: entity.clone(), action, act });
if !smooth(e, mid) {
add("edge.fillet", Act::Run("solid.fillet", json!({"edges": [pt(mid)], "radius": r})));
add("edge.chamfer", Act::Run("solid.chamfer", json!({"edges": [pt(mid)], "distance": r})));
// The midpoint is interior to this edge, but other bodies (including component
// copies in their local frames) can have coincident edges. Retain the picked body.
add("edge.fillet", Act::Run("solid.fillet", json!({"body": world, "edges": [pt(mid)], "radius": r})));
add("edge.chamfer", Act::Run("solid.chamfer", json!({"body": world, "edges": [pt(mid)], "distance": r})));
}
let dir = straight(pts);
if dir.is_some() {
@@ -1278,6 +1280,41 @@ fn rectangle(plane: Value, p0: [f64; 2], p1: [f64; 2]) -> [Value; 3] {
]
}
#[test]
fn edge_round_actions_keep_the_picked_body() {
for (corner, length) in [([10, 0, 20], 4), ([0, 0, 0], 20)] {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "Plate"})).unwrap();
s.execute(
"solid.box",
&json!({"corner": corner, "length": length, "width": length, "height": length, "body_name": "Block", "operation": "new"}),
)
.unwrap();
let mut tried = 0;
for a in actions(&s) {
if !a.entity.starts_with("Plate/") || !matches!(a.action, "edge.chamfer" | "edge.fillet") {
continue;
}
let Act::Run(id, ref p) = a.act else { continue };
let q = &p["edges"][0];
let x = q[0].as_f64().unwrap();
if q[1] != 0.0 || q[2] != 20.0 || !(0.0..20.0).contains(&x) {
continue;
}
// Block either meets this midpoint or coincides with the whole edge.
assert_eq!(p["body"], "Plate", "{}: {p}", a.action);
assert_eq!(q, &json!([10.0, 0.0, 20.0]));
let mut unscoped = p.clone();
unscoped.as_object_mut().unwrap().remove("body");
let err = s.scratch().execute(id, &unscoped).unwrap_err().to_string();
assert!(err.contains("edges meet"), "{err}");
perform(&mut s.scratch(), &a.act, &errors(&s)).unwrap();
tried += 1;
}
assert_eq!(tried, 2);
}
}
#[test]
fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
for (cmd, size) in [("solid.fillet", "radius"), ("solid.chamfer", "distance")] {
+99
View File
@@ -22,6 +22,19 @@ impl Scratch {
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
path
}
fn saving_script(&self) -> (PathBuf, PathBuf) {
let path = self.0.join("saving.json");
let saved = self.0.join("script-ran.solidcraft");
std::fs::write(
&path,
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
{"command": "file.save_as", "params": {"path": saved}}]})
.to_string(),
)
.unwrap();
(path, saved)
}
}
impl Drop for Scratch {
@@ -43,6 +56,92 @@ fn body_count(path: &Path) -> Value {
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
}
#[test]
fn run_rejects_invalid_options_before_script_execution_or_output_writes() {
let dir = Scratch::new();
let (script, script_saved) = dir.saving_script();
let cases = [
(&["--out"][..], "--out needs a value"),
(&["--save"][..], "--save needs a value"),
(&["--in"][..], "--in needs a value"),
(&["--out", "--quiet"][..], "--out needs a value"),
(&["--ouut", "part.step"][..], "doesn't take --ouut"),
(&["--unknown"][..], "doesn't take --unknown"),
];
for existing in [false, true] {
let saved = dir.0.join(format!("{existing}.solidcraft"));
let exported = dir.0.join(format!("{existing}.step"));
let report = dir.0.join(format!("{existing}.json"));
let outputs = [&saved, &exported, &report];
if existing {
for path in outputs {
std::fs::write(path, b"existing output").unwrap();
}
}
for (extra, message) in cases {
let output = cli()
.arg("run")
.arg(&script)
.arg("--save")
.arg(&saved)
.arg("--out")
.arg(&exported)
.arg("--json-out")
.arg(&report)
.args(extra)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1), "{extra:?}");
assert!(String::from_utf8_lossy(&output.stderr).contains(message), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty());
assert!(!script_saved.exists(), "invalid arguments must be rejected before the script runs");
for path in outputs {
if existing {
assert_eq!(std::fs::read(path).unwrap(), b"existing output");
} else {
assert!(!path.exists());
}
}
}
}
}
#[test]
fn eval_and_inspect_reject_invalid_options_before_script_execution_or_reporting() {
let dir = Scratch::new();
let (script, script_saved) = dir.saving_script();
let cases = [
(&["--in"][..], "--in needs a value"),
(&["--in", "--quiet"][..], "--in needs a value"),
(&["--out", "part.step"][..], "doesn't take --out"),
(&["--unknown"][..], "doesn't take --unknown"),
];
for command in ["eval", "inspect"] {
for existing in [false, true] {
let report = dir.0.join(format!("{command}-{existing}.json"));
if existing {
std::fs::write(&report, b"existing report").unwrap();
}
for (extra, message) in cases {
let output = cli().arg(command).arg(&script).arg("--json-out").arg(&report).args(extra).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{command} {extra:?}");
assert!(
String::from_utf8_lossy(&output.stderr).contains(message),
"{command} {extra:?}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stdout.is_empty());
assert!(!script_saved.exists(), "invalid arguments must be rejected before the script runs");
if existing {
assert_eq!(std::fs::read(&report).unwrap(), b"existing report");
} else {
assert!(!report.exists());
}
}
}
}
}
#[test]
fn cli_loads_both_design_encodings_by_contents() {
let dir = Scratch::new();
+1
View File
@@ -23,6 +23,7 @@ solidcraft-ui-egui = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
url = "2"
percent-encoding = "2"
[target.'cfg(target_arch = "wasm32")'.dependencies]
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
+12
View File
@@ -448,6 +448,18 @@ pub fn recovery_path() -> String {
format!("{OPFS}{RECOVERY}")
}
/// A recovery file lives in OPFS, but its relative links belong to the original design's
/// folder (which can be on disk). Resolve them before Open sees the temporary file location.
pub fn prepare_recovery(original_path: Option<&str>) -> Result<String, String> {
let path = recovery_path();
let bytes = vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?;
solidcraft_engine::resolve_design_links(&mut doc, original_path);
let bytes = solidcraft_engine::io::write_design_container(&doc).map_err(|e| e.to_string())?;
vfs::insert(&path, bytes);
Ok(path)
}
/// Pick files on the computer: the File System Access picker where there is one, else an
/// `<input type=file>`. Read files arrive in `files.incoming`.
pub fn pick_from_disk(files: &Rc<Files>, intent: Intent) {
+5 -4
View File
@@ -1,4 +1,4 @@
//! Bounded HTTPS downloads, handed to the normal design-open command as unsaved copies.
//! Bounded HTTPS or same-origin downloads, handed to the normal design-open command as unsaved copies.
use std::{cell::RefCell, rc::Rc};
use js_sys::{Reflect, Uint8Array};
@@ -87,14 +87,15 @@ impl Download {
}
async fn fetch(value: &str) -> Reply {
let url = design_url(value)?;
let window = web_sys::window().ok_or("No browser window")?;
let page_origin = window.location().origin().map_err(|_| "Cannot read page origin")?;
let url = design_url(value, &page_origin)?;
let name = url.path_segments().and_then(|mut segments| segments.next_back()).filter(|name| !name.is_empty()).unwrap_or("Design.solidcraft");
let decoded = js_sys::decode_uri_component(name).ok().and_then(|name| name.as_string()).unwrap_or_else(|| name.to_owned());
let name: String = decoded.strip_suffix(".solidcraft").unwrap_or(&decoded).chars().take(255).collect();
let window = web_sys::window().ok_or("No browser window")?;
let response = JsFuture::from(window.fetch_with_str(url.as_str())).await.map_err(|_| "Network request failed or the host refused CORS access")?;
let response: web_sys::Response = response.dyn_into().map_err(|_| "Invalid download response")?;
design_url(&response.url()).map_err(|_| "Design redirected to a URL that does not use HTTPS")?;
design_url(&response.url(), &page_origin).map_err(|_| "Design redirected to a URL that does not use HTTPS or the page's own origin")?;
if !response.ok() {
return Err(format!("HTTP {} {}", response.status(), response.status_text()));
}
+92 -19
View File
@@ -59,16 +59,44 @@ impl Launch {
fn persists(&self) -> bool {
!self.embed
}
fn full_app_url(&self, mut url: url::Url) -> String {
url.set_query(None);
url.set_fragment(None);
if let Some(open) = &self.open {
url.query_pairs_mut().append_pair("open", open);
}
url.to_string()
}
}
#[cfg(any(target_arch = "wasm32", test))]
fn design_url(value: &str) -> Result<url::Url, String> {
fn design_url(value: &str, page_origin: &str) -> Result<url::Url, String> {
if value.len() > 16_384 {
return Err("Design URL is too long".into());
}
let url = url::Url::parse(value).map_err(|_| "Invalid design URL")?;
if url.scheme() != "https" || url.host_str().is_none() || !url.username().is_empty() || url.password().is_some() {
return Err("Design links must use HTTPS without credentials".into());
let decoded = percent_encoding::percent_decode_str(value).decode_utf8().map_err(|_| "Invalid design URL encoding")?;
if decoded.chars().any(|ch| ch.is_control() || ch == '\\') {
return Err("Design links must not contain backslashes or control characters".into());
}
// Check before URL parsing normalizes away dot segments. Decode escaped slashes too.
let path = value.split(['?', '#']).next().unwrap_or_default();
let path = percent_encoding::percent_decode_str(path).collect::<Vec<_>>();
if path.split(|byte| *byte == b'/').any(|segment| segment == b"..") {
return Err("Design links must not contain parent path segments".into());
}
let page = url::Url::parse(page_origin).map_err(|_| "Invalid page origin")?;
let url = if value.starts_with('/') && !value.starts_with("//") {
page.join(value).map_err(|_| "Invalid design path")?
} else {
url::Url::parse(value).map_err(|_| "Invalid design URL")?
};
if (url.scheme() != "https" && url.origin() != page.origin())
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err("Design links must use HTTPS or the page's own origin without credentials".into());
}
Ok(url)
}
@@ -498,15 +526,25 @@ mod web {
});
});
let Some(recover) = answer else { return };
*self.files.recovery.borrow_mut() = None;
if recover {
self.app.open_path(&files::recovery_path());
let recovery = match files::prepare_recovery(path.as_deref()) {
Ok(recovery) => recovery,
Err(error) => {
self.app.set_status(error, true);
return;
}
};
self.app.open_path(&recovery);
if self.app.session.path.as_deref() != Some(recovery.as_str()) {
return;
}
// It belongs to its own file again (saving goes there); it is unsaved.
self.app.session.path = path;
self.app.session.doc_mut().name = name;
self.app.session.mark_unsaved();
self.app.home.open = false;
}
*self.files.recovery.borrow_mut() = None;
files::autosave(&self.files, None);
}
@@ -772,14 +810,7 @@ mod web {
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
let font_language = app.preferences.language.clone();
let linked = crate::linked::Download::start(launch.open.as_deref(), &cc.egui_ctx);
let full_url = window.location().href().ok().and_then(|value| url::Url::parse(&value).ok()).map(|mut url| {
url.set_query(None);
url.set_fragment(None);
if let Some(open) = &launch.open {
url.query_pairs_mut().append_pair("open", open);
}
url.to_string()
});
let full_url = window.location().href().ok().and_then(|value| url::Url::parse(&value).ok()).map(|url| launch.full_app_url(url));
Ok(Box::new(Shell {
app,
launch,
@@ -841,24 +872,53 @@ mod tests {
assert!(!Launch::parse("?other=embed&reopen=https://example.org").embed);
assert!(Launch::parse("?open=https://example.org/a").persists());
assert_eq!(Launch::parse("?open=first&open=second").open.as_deref(), Some("first"));
let launch = Launch::parse("?embed&open=/dutchman/jobs/abc/part.solidcraft%3Fx%3Da%2520b");
assert!(launch.embed);
assert!(!launch.persists());
let page = url::Url::parse("http://solidcraft.io/?embed#view").expect("synthetic page");
let full = url::Url::parse(&launch.full_app_url(page)).expect("full app URL");
let reopened = Launch::parse(full.query().unwrap_or_default());
assert_eq!(reopened.open, launch.open);
assert_eq!(reopened.open.as_deref(), Some("/dutchman/jobs/abc/part.solidcraft?x=a%20b"));
assert!(!reopened.embed);
assert!(reopened.persists());
assert!(full.fragment().is_none());
}
#[test]
fn design_links_refuse_hostile_schemes_credentials_and_sizes() {
assert!(design_url("https://example.org/arm.solidcraft?download=1").is_ok());
assert!(design_url("https://example.org/arm.solidcraft?download=1", "http://solidcraft.io").is_ok());
for value in [
"http://example.org/a",
"data:application/json,{}",
"blob:https://example.org/a",
"javascript:alert(1)",
"/arm.solidcraft",
"https://user:secret@example.org/a",
"//evil.example/x",
"/../x",
"/%2e%2e/x",
"/a/%2E./x",
"/a%2f..%2fx",
"/a\\x",
"/a%5cx",
"/a\nx",
"/a%00x",
"/a%7fx",
"/a%C2%85x",
"/a%3f/../x",
"/a/..#view",
"/a/..?download=1",
"https://solidcraft.io/a/../x",
"http://user:secret@solidcraft.io/x",
"http://solidcraft.io.evil.example/x",
"http://solidcraft.io:8080/x",
"relative.solidcraft",
"",
] {
assert!(design_url(value).is_err(), "{value}");
assert!(design_url(value, "http://solidcraft.io").is_err(), "{value}");
}
assert!(design_url(&format!("https://example.org/{}", "x".repeat(16_384))).is_err());
assert!(design_url("http://redirect.example.org/a").is_err());
assert!(design_url(&format!("https://example.org/{}", "x".repeat(16_384)), "http://solidcraft.io").is_err());
assert!(design_url("http://redirect.example.org/a", "http://solidcraft.io").is_err());
let cap = solidcraft_engine::io::MAX_DESIGN_BYTES;
assert!(check_download_size(0, cap as u64).is_ok());
@@ -870,6 +930,19 @@ mod tests {
assert!(check_download_size(usize::MAX, 0).is_err());
}
#[test]
fn design_links_allow_same_origin_paths_and_redirects() {
for origin in ["http://solidcraft.io", "https://solidcraft.io", "http://solidcraft.io:8080"] {
assert_eq!(design_url("/a/b.solidcraft", origin).map(|url| url.to_string()), Ok(format!("{origin}/a/b.solidcraft")));
assert!(design_url(&format!("{origin}/x"), origin).is_ok());
// Final response URLs follow the same origin and scheme rules as the request.
assert!(design_url("https://docs.solidcraft.io/x", origin).is_ok());
assert!(design_url("http://other/x", origin).is_err());
}
assert!(design_url("http://solidcraft.io/x", "https://solidcraft.io").is_err());
assert!(design_url("http://SOLIDCRAFT.io:80/x", "http://solidcraft.io").is_ok());
}
#[test]
fn only_ctrl_tab_is_left_to_the_browser() {
// Ctrl+Tab and Ctrl+Shift+Tab (the event's key is "Tab" either way) switch browser tabs.
+208 -6
View File
@@ -13,11 +13,15 @@ use solidcraft_engine::MAX_LINE;
use solidcraft_engine::control_auth;
use solidcraft_engine::control_pending;
use solidcraft_ui_egui::ControlRequest;
use solidcraft_ui_egui::control::{DESIGN_WATCH, DesignWatch, Subscriber};
/// Connections served at once, so a flood must not be able to spawn threads without end.
const MAX_CONNECTIONS: usize = 16;
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
const MAX_BAD_LINES: usize = 8;
/// How long a subscribed connection waits after a `design.changed` line before the next: what
/// changes meanwhile (a parameter dragged) goes out as one line, the latest (#6985).
const EVENT_GAP: Duration = Duration::from_millis(100);
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token files this launch wrote (its control port, the agent chat's endpoint), removed
@@ -365,10 +369,91 @@ fn busy_message(p: &Pending, method: &str) -> String {
)
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
/// A subscribed connection's socket, for its replies and its events: each line is written whole
/// and alone, so an event never lands inside a reply (#6985).
struct Lines {
socket: TcpStream,
turn: Mutex<()>,
}
impl Lines {
fn send(&self, line: &Value) -> std::io::Result<()> {
let _turn = self.turn.lock().unwrap_or_else(PoisonError::into_inner);
send(&mut &self.socket, line)
}
}
/// A connection's `control.subscribe` (#6985): its `design.changed` lines, sent from a thread of
/// their own until the connection ends.
struct Subscription {
lines: Arc<Lines>,
stop: Arc<AtomicBool>,
_counted: Subscriber,
}
impl Subscription {
/// Write `reply`, the window's answer to `control.subscribe` naming the design `seen`, on
/// `out`, then send `design`'s changes after it: the reply goes out before any event. `None`,
/// with nothing written, when no thread or socket was left for the events.
fn start(
design: &'static DesignWatch,
counted: Subscriber,
out: &TcpStream,
seen: (String, u64),
reply: &Value,
) -> Option<(Self, std::io::Result<()>)> {
let lines = Arc::new(Lines { socket: out.try_clone().ok()?, turn: Mutex::new(()) });
let stop = Arc::new(AtomicBool::new(false));
let first = lines.turn.lock().unwrap_or_else(PoisonError::into_inner);
let (l, s) = (lines.clone(), stop.clone());
std::thread::Builder::new().stack_size(64 * 1024).spawn(move || send_events(design, &l, &s, seen)).ok()?;
let sent = send(&mut &lines.socket, reply);
drop(first);
Some((Subscription { lines, stop, _counted: counted }, sent))
}
}
/// The design a `control.subscribe` reply names.
fn design_of(reply: &Value) -> Option<(String, u64)> {
let result = reply.get("result")?;
Some((result.get("document_id")?.as_str()?.to_string(), result.get("revision")?.as_u64()?))
}
impl Drop for Subscription {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
// The socket closes now, not when the thread next looks; a line blocked on a client that
// stopped reading ends too.
let _ = self.lines.socket.shutdown(std::net::Shutdown::Both);
}
}
/// The `design.changed` lines of a subscribed connection, each time the design differs from the
/// last one it was told of (`seen`), until `stop`.
fn send_events(design: &DesignWatch, lines: &Lines, stop: &AtomicBool, mut seen: (String, u64)) {
while !stop.load(Ordering::Relaxed) {
let Some(now) = design.wait(&seen, stop, Duration::from_secs(1)).filter(|now| *now != seen) else { continue };
if lines.send(&json!({"event": "design.changed", "params": {"document_id": now.0, "revision": now.1}})).is_err() {
break;
}
seen = now;
std::thread::sleep(EVENT_GAP);
}
}
/// Write `reply` on the connection: through its subscription once it has one.
fn answer(out: &mut TcpStream, events: Option<&Subscription>, reply: &Value) -> std::io::Result<()> {
match events {
Some(e) => e.lines.send(reply),
None => send(out, reply),
}
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING, &DESIGN_WATCH);
}
#[allow(clippy::too_many_arguments)]
fn serve_with(
stream: TcpStream,
token: &str,
@@ -377,6 +462,7 @@ fn serve_with(
patient: bool,
waits: Waits,
running: &'static Running,
design: &'static DesignWatch,
) {
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
let Ok(read) = stream.try_clone() else { return };
@@ -385,12 +471,15 @@ fn serve_with(
let mut bad = 0usize;
let mut authenticated = false;
let mut pending: Option<Pending> = None;
let mut events: Option<Subscription> = None;
loop {
// A first `control.subscribe` on its way to the window (#6985).
let mut joining = None;
let line = match read_line(&mut reader, MAX_LINE) {
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
Ok(None) => break,
Err(e) => {
let _ = send(&mut out, &json!({"ok": false, "error": e.to_string()}));
let _ = answer(&mut out, events.as_ref(), &json!({"ok": false, "error": e.to_string()}));
break;
}
};
@@ -434,6 +523,14 @@ fn serve_with(
} else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) {
// Nothing more is queued behind it, to run unseen when it ends.
json!({"ok": false, "busy": true, "error": busy_message(p, &method)})
} else if method == "control.subscribe" && events.is_some() {
if late.is_some() {
// As below: finished, and the client moved on without asking.
pending = None;
}
// Its events already say how the design moves; a second answer could only
// arrive behind a newer one.
json!({"ok": false, "error": "already subscribed: this connection is sent design.changed lines; ask document.revision for the design now"})
} else {
if late.is_some() {
// Finished, and the client moved on without asking: it is dropped.
@@ -443,6 +540,9 @@ fn serve_with(
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
let what = control_pending::describe(&method, &params);
let effect = control_pending::effect(&method, &params);
// Counted before the window answers, so it publishes every change from its
// answer on.
joining = (method == "control.subscribe").then(|| design.join());
let (req, rrx) = ControlRequest::new(method.clone(), params);
let ticket = running.add(connection, &id, &req);
if tx.send(req).is_err() {
@@ -472,7 +572,20 @@ fn serve_with(
json!({"ok": false, "error": format!("bad JSON: {e}")})
}
};
if send(&mut out, &reply).is_err() {
// Subscribed once the window named the design (not when it refused or timed out).
let sent = match joining.and_then(|counted| design_of(&reply).map(|seen| (counted, seen))) {
Some((counted, seen)) => match Subscription::start(design, counted, &out, seen, &reply) {
Some((subscription, sent)) => {
events = Some(subscription);
sent
}
None => {
send(&mut out, &json!({"id": reply.get("id"), "ok": false, "error": "not subscribed: no thread or socket left for the events"}))
}
},
None => answer(&mut out, events.as_ref(), &reply),
};
if sent.is_err() {
break;
}
if bad >= MAX_BAD_LINES {
@@ -512,7 +625,7 @@ mod tests {
static RUNNING: Running = Running::new();
std::thread::spawn(move || {
if let Some(stream) = listener.incoming().flatten().next() {
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING, &DESIGN_WATCH);
}
});
let s = TcpStream::connect(addr).unwrap();
@@ -578,7 +691,7 @@ mod tests {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING));
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING, &DESIGN_WATCH));
}
});
let open = || {
@@ -648,6 +761,95 @@ mod tests {
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
}
/// #6985: a connection that sends `control.subscribe` gets the design in the reply, then a
/// `design.changed` line each time the window publishes another: never before the reply,
/// none for the same design, the latest after a burst, each line whole between its replies.
/// A second subscribe is refused without reaching the window. A connection that did not
/// subscribe gets no events, and one that closed is no longer counted.
#[test]
fn subscribed_connections_are_told_of_design_changes() {
static RUNNING: Running = Running::new();
static WATCH: DesignWatch = DesignWatch::new();
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
// The window: it answers `control.subscribe` as the app does, publishing the design first;
// then the design changes at once, before the reply may have been written.
std::thread::spawn(move || {
for req in rx {
if req.method == "control.subscribe" {
WATCH.publish("d-1", 3);
let _ = req.reply.send(json!({"ok": true, "result": {"document_id": "d-1", "revision": 3}}));
WATCH.publish("d-1", 4);
} else {
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
}
});
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, WAITS, &RUNNING, &WATCH));
}
});
let open = || {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
(s.try_clone().unwrap(), BufReader::new(s))
};
let next = |lines: &mut BufReader<TcpStream>| -> Value {
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
};
let changed = |id: &str, revision: u64| json!({"event": "design.changed", "params": {"document_id": id, "revision": revision}});
let (mut quiet_out, mut quiet) = open();
writeln!(quiet_out, "{}", json!({"id": 1, "method": "ui.view", "token": token})).unwrap();
assert_eq!(next(&mut quiet)["id"], 1);
let (mut out, mut lines) = open();
writeln!(out, "{}", json!({"id": 1, "method": "control.subscribe", "token": token})).unwrap();
assert_eq!(next(&mut lines), json!({"id": 1, "ok": true, "result": {"document_id": "d-1", "revision": 3}}));
assert_eq!(next(&mut lines), changed("d-1", 4), "the change after the window's answer comes after the reply");
assert!(WATCH.subscribed());
// The design it was told of is no news; another revision is.
WATCH.publish("d-1", 4);
WATCH.publish("d-1", 5);
assert_eq!(next(&mut lines), changed("d-1", 5));
// A burst ends with its latest.
for revision in 6..=10 {
WATCH.publish("d-1", revision);
}
let mut last = 5;
while last < 10 {
let e = next(&mut lines);
let revision = e["params"]["revision"].as_u64().unwrap();
assert!(e["event"] == "design.changed" && revision > last, "{e} after {last}");
last = revision;
}
// Another tab: another id, whatever its revision.
WATCH.publish("d-2", 2);
assert_eq!(next(&mut lines), changed("d-2", 2));
// Subscribed already: refused here (the window would have published d-1 again).
writeln!(out, "{}", json!({"id": 2, "method": "control.subscribe"})).unwrap();
let r = next(&mut lines);
assert!(r["id"] == 2 && r["ok"] == false && r["error"].as_str().is_some_and(|e| e.starts_with("already subscribed")), "{r}");
// Replies still come, whole, and nothing else was sent meanwhile.
writeln!(out, "{}", json!({"id": 3, "method": "ui.view"})).unwrap();
assert_eq!(next(&mut lines), json!({"id": 3, "ok": true, "result": "ui.view"}));
writeln!(quiet_out, "{}", json!({"id": 2, "method": "ui.view"})).unwrap();
assert_eq!(next(&mut quiet), json!({"id": 2, "ok": true, "result": "ui.view"}), "no events without subscribing");
// Closed, it is no longer counted.
drop((out, lines));
let deadline = Instant::now() + Duration::from_secs(10);
while WATCH.subscribed() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!WATCH.subscribed());
}
/// A timed-out request that only reads is not said to "still apply", nor is one that only
/// writes a file (an export, #1989); a change is, by name.
#[test]
+71 -11
View File
@@ -28,6 +28,7 @@ mod native_crash;
mod native_frame;
#[cfg(target_os = "macos")]
mod native_menu;
mod nav_bridge;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
@@ -87,6 +88,8 @@ struct App {
space_mouse: space_mouse::SpaceMouse,
#[cfg(target_os = "macos")]
native_menu: Option<native_menu::NativeMenu>,
/// A device's own app moving the view (Preferences › Navigation, off by default).
nav_bridge: nav_bridge::NavBridge,
}
impl eframe::App for App {
@@ -145,6 +148,8 @@ impl eframe::App for App {
if !self.solve.file_picker_busy() {
self.space_mouse.tick(&mut self.solve, ctx);
}
// Every frame, so switching the bridge off reaches the reader while a picker is open too.
self.nav_bridge.tick(&mut self.solve, ctx);
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -182,6 +187,10 @@ impl eframe::App for App {
}
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_ui_egui::open_elsewhere::close(&mut self.solve);
// Before a restart below starts the next instance, which would otherwise get nothing from
// the 3D mouse driver (`solidcraft_3dx_client::stop`).
#[cfg(target_os = "macos")]
solidcraft_3dx_client::stop();
solidcraft_engine::crash::end_session();
if let Some(lost_at) = self.gpu.as_ref().and_then(|g| g.lost_at) {
device_lost::restart(self.gpu.as_ref().is_some_and(|g| g.restart_on_software), lost_at);
@@ -633,6 +642,18 @@ fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
const APP_ID: &str = solidcraft_engine::io::APP_ID;
/// Disconnects from the 3D mouse driver when dropped, as `main` returns or a panic unwinds out
/// of it (`solidcraft_3dx_client::stop`).
#[cfg(target_os = "macos")]
struct DriverClient;
#[cfg(target_os = "macos")]
impl Drop for DriverClient {
fn drop(&mut self) {
solidcraft_3dx_client::stop();
}
}
fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
objc2_foundation::NSProcessInfo::processInfo().setProcessName(objc2_foundation::ns_string!("SolidCraft"));
@@ -697,6 +718,12 @@ fn main() -> eframe::Result {
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let fallback_note = std::env::var(startup::FALLBACK_NOTE_ENV).ok().map(|s| s.chars().take(4096).collect::<String>());
if let Some(note) = &fallback_note {
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
notes.push(note.clone());
}
let saved_samples = if safe_mode { None } else { msaa() };
let wgpu_options = wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref());
let depth_bits = if !safe_mode && startup::float_depth_everywhere(&wgpu_options) { FLOAT_DEPTH_BITS } else { DEPTH_BITS };
@@ -775,6 +802,10 @@ fn main() -> eframe::Result {
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_app = created.clone();
let starting = settings.clone();
// A panic on the window's thread ends the app without `on_exit`; this disconnects from the
// 3D mouse driver then too.
#[cfg(target_os = "macos")]
let _driver_client = DriverClient;
mark("run_native");
let result = eframe::run_native(
"SolidCraft",
@@ -805,6 +836,7 @@ fn main() -> eframe::Result {
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.cpu_meshing = safe_mode;
app.graphics_fallback = fallback_note.clone();
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
app.load_prefs(&p);
}
@@ -906,8 +938,9 @@ fn main() -> eframe::Result {
if !notes.is_empty() {
app.set_status(notes.join(" "), true);
}
let nav_bridge = nav_bridge::NavBridge::start(&cc.egui_ctx);
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx, nav_bridge.claim());
#[cfg(target_os = "macos")]
let native_menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) {
Ok(menu) => Some(menu),
@@ -932,12 +965,13 @@ fn main() -> eframe::Result {
space_mouse,
#[cfg(target_os = "macos")]
native_menu,
nav_bridge,
}))
}),
);
control_server::remove_token_file();
match result {
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, started, settings.as_deref()),
r => r,
}
}
@@ -958,6 +992,9 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
// A retry applies only to this launch. WGPU_BACKEND always has priority.
let retry = std::env::var(startup::RETRY_BACKEND_ENV).ok();
let preference = if cpu { preference } else { retry.as_deref().or(preference) };
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
eprintln!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
@@ -979,24 +1016,47 @@ fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuC
options
}
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
/// the software renderer, else a message box saying what failed instead of a silent exit.
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
/// A failed renderer returns here before app creation. A fresh process avoids reusing winit's
/// event loop or a failed device; each retry advances through the APIs, then software once.
fn no_window(error: eframe::Error, tried_cpu: bool, failed: eframe::wgpu::Backends, settings: Option<&std::path::Path>) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
// This failure was handled, so neither the marker nor the journal should call it a crash.
if let Some(dir) = settings {
startup::started(dir);
}
solidcraft_engine::crash::end_session();
let explicit = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
if let Some(next) = startup::fallback_backend(cfg!(target_os = "windows"), explicit, tried_cpu, failed)
&& let Ok(exe) = std::env::current_exe()
{
let previous = std::env::var(startup::FALLBACK_NOTE_ENV).unwrap_or_default();
let note = startup::fallback_note(&previous, failed, next, &error.to_string());
log::warn!("{note}");
eprintln!("SolidCraft: {note}");
let mut command = std::process::Command::new(exe);
command.args(std::env::args_os().skip(1)).env(startup::FALLBACK_NOTE_ENV, note);
if next == "cpu" {
command.env(startup::RENDERER_ENV, "cpu").env_remove(startup::RETRY_BACKEND_ENV);
} else {
command.env(startup::RETRY_BACKEND_ENV, next);
}
match command.status() {
Ok(status) if status.success() => return Ok(()),
// The second try showed its own message.
// The final try showed its own message.
Ok(_) => return Err(error),
Err(e) => log::error!("could not start again with the software renderer: {e}"),
Err(e) => log::error!("could not start again on {next}: {e}"),
}
}
let message = if explicit {
format!("SolidCraft could not open its window with WGPU_BACKEND.\n\n{error}\n\nChange or remove WGPU_BACKEND to try another graphics API.")
} else {
startup::no_window_message(&error.to_string(), settings)
};
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("SolidCraft could not start")
.set_description(startup::no_window_message(&error.to_string(), settings))
.set_description(message)
.set_buttons(rfd::MessageButtons::Ok)
.show();
Err(error)
+463
View File
@@ -0,0 +1,463 @@
//! The navigation bridge: a 3D input device's own program (a homemade device's driver app) moves
//! the view over a loopback connection, the way a 3D mouse driver hands its axes to the programs
//! it supports, so the device emulates no mouse and the pointer stays the user's.
//!
//! The device app listens on `127.0.0.1:<port>`; with Preferences › Navigation › "Navigate with a
//! device app" on (off by default), SolidCraft connects to it, says hello and reads one JSON
//! object per line (docs/3d-mouse.md). The reader thread only sums the motion; the egui thread
//! hands it to the view once per frame (`space_mouse::bridge_frame`). A device the app says it
//! reads is left to it by the 3D mouse reader ([`Claim`]), so the view doesn't move twice.
use std::io::{ErrorKind, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use egui::Context;
use serde_json::{Value, json};
use solidcraft_ui_egui::SolveApp;
use solidcraft_ui_egui::space_mouse::{BRIDGE_VIEWS, BridgeMotion, bridge_frame, bridge_step};
/// The longest line read; a longer one is skipped to its end.
const MAX_LINE: usize = 4096;
/// Standard views waiting for the next frame; past this many, more are a flood and dropped.
const MAX_VIEWS: usize = 8;
/// The longest name a device app may give for itself or its device, in characters.
const MAX_NAME: usize = 64;
/// How long a connection attempt waits.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
/// How long a read waits for data before the reader looks at the preferences again.
const READ_WAKE: Duration = Duration::from_millis(250);
/// The wait before connecting again when no device app answered or one went away.
const RETRY_AFTER: Duration = Duration::from_secs(1);
/// In zoom mode, a motion of this many points along Y is one zoom step: with 8 % a step, the 1 %
/// a point that a zoom drag moves.
const POINTS_PER_ZOOM_STEP: f64 = 8.0;
/// What the device's motion drives, as the device app last said (`mode`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Mode {
#[default]
Orbit,
Pan,
Zoom,
}
/// What the reader thread and the egui thread share.
#[derive(Debug, Default)]
struct Shared {
/// The port the preferences ask to connect to; none while the bridge is off.
wanted: Option<u16>,
/// The device app connected, by the name it gave ("?" until it gives one).
connected: Option<String>,
/// The device the connected app reads, as it named it: the 3D mouse reader leaves that one alone.
device: Option<String>,
/// Motion since the egui thread last took it.
motion: BridgeMotion,
/// Standard views asked for since then.
views: Vec<String>,
}
fn lock(shared: &Mutex<Shared>) -> Option<MutexGuard<'_, Shared>> {
shared.lock().ok()
}
/// A name a device app gave, without control characters and cut to [`MAX_NAME`]; none when empty.
fn name(value: Option<&Value>) -> Option<String> {
let name: String = value?.as_str()?.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
let name = name.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// A number of a message, finite and in range; none when it isn't one.
fn number(msg: &Value, key: &str) -> f64 {
bridge_step(msg.get(key).and_then(Value::as_f64).unwrap_or(0.0))
}
/// Takes one line from the device app into `shared`. Lines that aren't a message this version
/// knows are ignored. Returns whether the view has something new to show.
fn consume(line: &[u8], mode: &mut Mode, shared: &mut Shared) -> bool {
let Ok(msg) = serde_json::from_slice::<Value>(line) else { return false };
let add = |shared: &mut Shared, motion: BridgeMotion| {
shared.motion = shared.motion.plus(motion);
!motion.is_still()
};
match msg.get("t").and_then(Value::as_str) {
Some("axis") => {
let (x, y) = (number(&msg, "x"), number(&msg, "y"));
let motion = match mode {
Mode::Orbit => BridgeMotion { orbit: [x, y], ..Default::default() },
Mode::Pan => BridgeMotion { pan: [x, y], ..Default::default() },
// Up zooms in, as a zoom drag does.
Mode::Zoom => BridgeMotion { zoom: -y / POINTS_PER_ZOOM_STEP, ..Default::default() },
};
add(shared, motion)
}
Some("zoom") => add(shared, BridgeMotion { zoom: number(&msg, "d"), ..Default::default() }),
Some("mode") => {
let on = msg.get("on").and_then(Value::as_bool).unwrap_or(false);
*mode = match msg.get("mode").and_then(Value::as_str) {
Some("pan") if on => Mode::Pan,
Some("zoom") if on => Mode::Zoom,
_ => Mode::Orbit,
};
false
}
Some("view") => {
let view = msg.get("name").and_then(Value::as_str).and_then(|n| BRIDGE_VIEWS.iter().find(|v| **v == n));
match view {
Some(view) if shared.views.len() < MAX_VIEWS => {
shared.views.push((*view).to_string());
true
}
_ => false,
}
}
Some("hello") => {
shared.connected = name(msg.get("server")).or_else(|| shared.connected.clone());
shared.device = name(msg.get("device"));
true
}
_ => false,
}
}
/// Splits what arrives into lines, across reads. A line longer than [`MAX_LINE`] is dropped up to
/// its newline, so a peer that never sends one can't make the reader hold more than that.
#[derive(Default)]
struct Lines {
line: Vec<u8>,
skipping: bool,
}
impl Lines {
fn feed(&mut self, mut data: &[u8], mut each: impl FnMut(&[u8])) {
while !data.is_empty() {
let end = data.iter().position(|b| *b == b'\n');
let (part, rest) = match end {
Some(i) => (data.get(..i).unwrap_or_default(), data.get(i + 1..).unwrap_or_default()),
None => (data, &[][..]),
};
if !self.skipping {
let room = (MAX_LINE + 1).saturating_sub(self.line.len());
self.line.extend_from_slice(part.get(..room.min(part.len())).unwrap_or_default());
if self.line.len() > MAX_LINE {
self.line.clear();
self.skipping = true;
}
}
if end.is_some() {
if !self.skipping {
each(self.line.strip_suffix(b"\r").unwrap_or(&self.line));
}
self.line.clear();
self.skipping = false;
}
data = rest;
}
}
}
/// What SolidCraft says first, so the device app knows which program it is driving.
fn hello() -> Value {
json!({"t": "hello", "app": "solidcraft", "version": env!("CARGO_PKG_VERSION")})
}
/// Reads one connection until the device app closes it, it fails, or the preferences no longer
/// ask for this port; then forgets what it had.
fn serve(mut stream: TcpStream, port: u16, ctx: &Context, shared: &Mutex<Shared>) {
// Without a read timeout the reader couldn't notice the bridge being switched off.
if stream.set_read_timeout(Some(READ_WAKE)).is_err() || writeln!(stream, "{}", hello()).is_err() {
return;
}
let _ = stream.set_nodelay(true);
log::info!("navigation bridge: connected to 127.0.0.1:{port}");
if let Some(mut shared) = lock(shared) {
shared.connected = Some("?".into());
}
ctx.request_repaint();
let (mut lines, mut mode) = (Lines::default(), Mode::default());
let mut chunk = [0_u8; 4096];
while lock(shared).is_some_and(|shared| shared.wanted == Some(port)) {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(size) => {
let mut news = false;
if let Some(mut shared) = lock(shared) {
lines.feed(chunk.get(..size).unwrap_or_default(), |line| news |= consume(line, &mut mode, &mut shared));
}
if news {
ctx.request_repaint();
}
}
Err(error) if matches!(error.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => {}
Err(error) => {
log::info!("navigation bridge: connection lost: {error}");
break;
}
}
}
if let Some(mut shared) = lock(shared) {
*shared = Shared { wanted: shared.wanted, ..Shared::default() };
}
ctx.request_repaint();
}
/// Connects to the device app while the preferences ask for it, again whenever it goes away.
fn reader(ctx: Context, shared: Arc<Mutex<Shared>>) {
loop {
let Some(port) = lock(&shared).and_then(|shared| shared.wanted) else {
std::thread::sleep(READ_WAKE);
continue;
};
if let Ok(stream) = TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), CONNECT_TIMEOUT) {
serve(stream, port, &ctx, &shared);
}
std::thread::sleep(RETRY_AFTER);
}
}
/// Which 3D mouse the connected device app reads, for the 3D mouse reader to leave alone. Only
/// the Windows and macOS readers know a device's strings to compare.
#[derive(Clone)]
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
pub struct Claim(Arc<Mutex<Shared>>);
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
impl Claim {
/// Whether a device with these manufacturer and product strings is the one the connected
/// device app reads (its name appears in one, ignoring case).
pub fn claims(&self, strings: &[String]) -> bool {
let Some(device) = lock(&self.0).and_then(|shared| shared.device.clone()) else { return false };
let device = device.to_lowercase();
strings.iter().any(|s| s.to_lowercase().contains(&device))
}
}
pub struct NavBridge {
shared: Arc<Mutex<Shared>>,
}
impl NavBridge {
pub fn start(ctx: &Context) -> Self {
let shared = Arc::new(Mutex::new(Shared::default()));
let (worker_ctx, worker_state) = (ctx.clone(), Arc::clone(&shared));
if let Err(error) = std::thread::Builder::new().name("solidcraft-nav-bridge".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("navigation bridge: reader could not start: {error}");
}
Self { shared }
}
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
pub fn claim(&self) -> Claim {
Claim(Arc::clone(&self.shared))
}
/// Each frame: passes the preferences to the reader, and the motion and views since the last
/// frame to the view. Motion that arrived while the window had no focus is dropped with it.
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let wanted = Some(app.preferences.nav_bridge_port).filter(|port| app.preferences.nav_bridge_enabled && *port != 0);
let Some((motion, views, connected)) = lock(&self.shared).map(|mut shared| {
shared.wanted = wanted;
(std::mem::take(&mut shared.motion), std::mem::take(&mut shared.views), shared.connected.clone())
}) else {
return;
};
app.space_mouse.bridge = connected.filter(|_| wanted.is_some());
if !motion.is_still() || !views.is_empty() {
bridge_frame(app, ctx, motion, &views);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader};
use std::net::TcpListener;
use std::time::Instant;
fn feed(shared: &mut Shared, mode: &mut Mode, text: &str) -> bool {
let mut news = false;
Lines::default().feed(text.as_bytes(), |line| news |= consume(line, mode, shared));
news
}
#[test]
fn axis_motion_orbits_pans_or_zooms_as_the_mode_says() {
let (mut shared, mut mode) = (Shared::default(), Mode::default());
assert!(feed(&mut shared, &mut mode, "{\"t\":\"axis\",\"x\":3,\"y\":-2}\n{\"t\":\"axis\",\"x\":1.5,\"y\":0}\n"));
assert_eq!(shared.motion, BridgeMotion { orbit: [4.5, -2.0], ..Default::default() });
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"pan\",\"on\":true}\n{\"t\":\"axis\",\"x\":5,\"y\":6}\n");
assert_eq!(mode, Mode::Pan);
assert_eq!(shared.motion.pan, [5.0, 6.0]);
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"zoom\",\"on\":true}\n{\"t\":\"axis\",\"x\":0,\"y\":-16}\n");
assert_eq!(shared.motion.zoom, 2.0, "up zooms in, 8 points a step");
// Off, whichever mode it names, is back to orbit.
feed(&mut shared, &mut mode, "{\"t\":\"mode\",\"mode\":\"pan\",\"on\":false}\n");
assert_eq!(mode, Mode::Orbit);
// The ring's steps add up with the zoom mode's.
feed(&mut shared, &mut mode, "{\"t\":\"zoom\",\"d\":-0.5}\n");
assert_eq!(shared.motion.zoom, 1.5);
}
#[test]
fn views_are_queued_by_their_known_names_only() {
let (mut shared, mut mode) = (Shared::default(), Mode::default());
assert!(feed(&mut shared, &mut mode, "{\"t\":\"view\",\"name\":\"iso\"}\n{\"t\":\"view\",\"name\":\"fit\"}\n"));
assert!(!feed(&mut shared, &mut mode, "{\"t\":\"view\",\"name\":\"ISO\"}\n{\"t\":\"view\",\"name\":\"../etc\"}\n{\"t\":\"view\"}\n"));
assert_eq!(shared.views, ["iso", "fit"]);
let flood = "{\"t\":\"view\",\"name\":\"top\"}\n".repeat(100);
feed(&mut shared, &mut mode, &flood);
assert_eq!(shared.views.len(), MAX_VIEWS);
}
#[test]
fn hostile_lines_never_move_the_view_out_of_range() {
let (mut shared, mut mode) = (Shared::default(), Mode::default());
let lines = [
"not json",
"{\"t\":\"axis\",\"x\":\"5\",\"y\":null}",
"{\"t\":\"axis\",\"x\":1e308,\"y\":-1e308}",
"{\"t\":\"axis\",\"x\":1e999}",
"{\"t\":\"zoom\",\"d\":[1]}",
"{\"t\":42}",
"[1,2,3]",
"{\"t\":\"mode\",\"mode\":{\"x\":1},\"on\":\"yes\"}",
"\u{0}\u{1}\u{2}",
];
for line in lines {
Lines::default().feed(format!("{line}\n").as_bytes(), |l| {
consume(l, &mut mode, &mut shared);
});
}
let limit = solidcraft_ui_egui::space_mouse::BRIDGE_MAX_STEP;
let values = shared.motion.orbit.into_iter().chain(shared.motion.pan).chain([shared.motion.zoom]);
for value in values {
assert!(value.is_finite() && value.abs() <= limit, "{value}");
}
assert_eq!(mode, Mode::Orbit);
// Invalid UTF-8 is no message either.
assert!(!consume(&[0xff, 0xfe, b'{', b'}'], &mut mode, &mut shared));
}
#[test]
fn lines_split_across_reads_and_long_ones_are_skipped_to_their_end() {
let mut got = Vec::new();
let mut lines = Lines::default();
for part in ["{\"t\":", "\"zoom\"}\r\n{\"a\"", ":1}\n"] {
lines.feed(part.as_bytes(), |line| got.push(String::from_utf8_lossy(line).to_string()));
}
assert_eq!(got, ["{\"t\":\"zoom\"}", "{\"a\":1}"]);
got.clear();
let long = vec![b'x'; MAX_LINE * 3];
lines.feed(&long, |line| got.push(String::from_utf8_lossy(line).to_string()));
lines.feed(&long, |line| got.push(String::from_utf8_lossy(line).to_string()));
assert!(lines.line.len() <= MAX_LINE + 1, "never holds more than one line");
lines.feed(b"tail\nnext\n", |line| got.push(String::from_utf8_lossy(line).to_string()));
assert_eq!(got, ["next"], "the long line is dropped to its newline, the next one read");
}
#[test]
fn the_device_app_names_itself_and_the_device_it_leaves_alone() {
let shared = Arc::new(Mutex::new(Shared::default()));
let claim = Claim(Arc::clone(&shared));
let strings = ["STMicroelectronics".to_string(), "MAKMOUSE 3D".to_string()];
assert!(!claim.claims(&strings), "nothing is claimed before an app says so");
let mut mode = Mode::default();
if let Ok(mut shared) = shared.lock() {
let hello = format!("{{\"t\":\"hello\",\"server\":\"Dev\\u0007App {}\",\"device\":\"makmouse\"}}\n", "x".repeat(200));
feed(&mut shared, &mut mode, &hello);
assert_eq!(shared.connected.as_deref().map(|n| n.chars().count()), Some(MAX_NAME));
assert!(shared.connected.as_deref().is_some_and(|n| n.starts_with("DevApp")));
}
assert!(claim.claims(&strings), "the name matches ignoring case");
assert!(!claim.claims(&["3Dconnexion".to_string(), "SpaceMouse Compact".to_string()]), "another 3D mouse stays the reader's");
}
/// Waits until `check` holds, or fails after a few seconds.
fn wait_for(what: &str, mut check: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
while !check() {
assert!(Instant::now() < deadline, "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_connection_says_hello_reads_the_stream_and_ends_when_the_bridge_is_switched_off() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let shared = Arc::new(Mutex::new(Shared { wanted: Some(port), ..Default::default() }));
let client = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let (mut server, _) = listener.accept().unwrap();
let worker = {
let shared = Arc::clone(&shared);
std::thread::spawn(move || serve(client, port, &Context::default(), &shared))
};
let mut hello_line = String::new();
BufReader::new(server.try_clone().unwrap()).read_line(&mut hello_line).unwrap();
let hello: Value = serde_json::from_str(&hello_line).unwrap();
assert_eq!((hello["t"].as_str(), hello["app"].as_str()), (Some("hello"), Some("solidcraft")));
server
.write_all(b"{\"t\":\"hello\",\"server\":\"MAKMOUSE\",\"device\":\"MAKMOUSE\"}\n{\"t\":\"axis\",\"x\":2,\"y\":1}\n{\"t\":\"view\",\"name\":\"top\"}\n")
.unwrap();
wait_for("the stream", || shared.lock().is_ok_and(|s| s.motion.orbit == [2.0, 1.0] && s.views == ["top"]));
assert_eq!(shared.lock().unwrap().connected.as_deref(), Some("MAKMOUSE"));
// Switched off: the connection ends and what it had is forgotten.
shared.lock().unwrap().wanted = None;
worker.join().unwrap();
let after = shared.lock().unwrap();
assert!(after.connected.is_none() && after.device.is_none() && after.motion.is_still() && after.views.is_empty());
}
#[test]
fn a_closed_connection_forgets_the_app() {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let shared = Arc::new(Mutex::new(Shared { wanted: Some(port), ..Default::default() }));
let client = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).unwrap();
let (server, _) = listener.accept().unwrap();
let worker = {
let shared = Arc::clone(&shared);
std::thread::spawn(move || serve(client, port, &Context::default(), &shared))
};
wait_for("the connection", || shared.lock().is_ok_and(|s| s.connected.is_some()));
drop(server);
worker.join().unwrap();
let after = shared.lock().unwrap();
assert_eq!((after.connected.as_deref(), after.wanted), (None, Some(port)), "still wanted, so the reader connects again");
}
#[test]
fn each_frame_hands_the_view_the_motion_and_passes_the_preferences_on() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
app.home.open = false;
let ctx = Context::default();
let _ = ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {});
let mut bridge = NavBridge { shared: Arc::new(Mutex::new(Shared::default())) };
let queue = |bridge: &NavBridge| {
let mut shared = bridge.shared.lock().unwrap();
shared.connected = Some("MAKMOUSE".into());
shared.motion = BridgeMotion { orbit: [10.0, 0.0], ..Default::default() };
};
// Off (the default): nothing is wanted, nothing reaches the view.
queue(&bridge);
bridge.tick(&mut app, &ctx);
assert_eq!(bridge.shared.lock().unwrap().wanted, None);
assert!(app.viewport.bridge_motion.is_none() && app.space_mouse.bridge.is_none());
// On: the port is passed to the reader and the motion to the view, once.
app.preferences.nav_bridge_enabled = true;
app.preferences.nav_bridge_port = 9123;
queue(&bridge);
bridge.tick(&mut app, &ctx);
assert_eq!(bridge.shared.lock().unwrap().wanted, Some(9123));
assert_eq!(app.space_mouse.bridge.as_deref(), Some("MAKMOUSE"));
assert_eq!(app.viewport.bridge_motion.map(|m| m.orbit), Some([10.0, 0.0]));
assert!(bridge.shared.lock().unwrap().motion.is_still(), "taken, not handed over twice");
// Port 0 is no port.
app.preferences.nav_bridge_port = 0;
bridge.tick(&mut app, &ctx);
assert_eq!(bridge.shared.lock().unwrap().wanted, None);
}
}
+26 -4
View File
@@ -528,6 +528,9 @@ struct Candidate<D> {
device: D,
layout: Layout,
ids: (u16, u16),
/// The device's manufacturer and product strings ([`device_strings`]).
#[cfg_attr(not(any(target_os = "windows", target_os = "macos")), allow(dead_code))]
strings: Vec<String>,
}
/// Input reports read with a timeout in milliseconds: hidapi's devices, and the tests' simulated
@@ -634,8 +637,15 @@ fn follow<D: ReportSource>(mut candidates: Vec<Candidate<D>>, now: impl Fn() ->
}
}
/// The device's manufacturer and product strings, which say whether a device app has it
/// ([`crate::nav_bridge::Claim`]). Asked of the opened 3D mouse only.
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
fn device_strings(device: &hidapi::HidDevice) -> Vec<String> {
[device.get_manufacturer_string(), device.get_product_string()].into_iter().filter_map(|s| s.ok().flatten()).collect()
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>, bridge: crate::nav_bridge::Claim) {
let Ok(mut api) = hid_api() else {
log::warn!("3D mouse: HID initialization failed");
return;
@@ -657,11 +667,17 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
// Read when the device was opened, from this device alone.
let info = device.get_device_info().ok()?;
let layout = device_layout(&device, info.vendor_id())?;
Some(Candidate { device, layout, ids: (info.vendor_id(), info.product_id()) })
let strings = device_strings(&device);
Some(Candidate { device, layout, ids: (info.vendor_id(), info.product_id()), strings })
})
.collect();
let names: Vec<_> = open.iter().map(|candidate| (candidate.ids, candidate.strings.clone())).collect();
// Whether the device read now is one a device app has: it moves the view over the navigation
// bridge, so read here as well the device would move it twice. What was read before ages out.
let mut strings: &[String] = &[];
follow(open, Instant::now, |event| match event {
Followed::Device(ids, buttons) => {
strings = names.iter().find(|(known, _)| *known == ids).map_or(&[][..], |(_, strings)| strings.as_slice());
connected(&ctx, &shared, Source::Hid(Some(ids)));
// Taken over from another device: what it reported meanwhile is its baseline.
if let Some(buttons) = buttons
@@ -670,6 +686,7 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
reading.set_buttons(buttons);
}
}
Followed::Report(..) if bridge.claims(strings) => {}
Followed::Report(layout, report) => publish(&ctx, &shared, *layout, report),
Followed::Gone => reset(&ctx, &shared),
});
@@ -1054,7 +1071,8 @@ pub struct SpaceMouse {
}
impl SpaceMouse {
pub fn start(ctx: &Context) -> Self {
/// Starts the reader. A device that `bridge` says its own app reads is left to that app.
pub fn start(ctx: &Context, bridge: crate::nav_bridge::Claim) -> Self {
let now = Instant::now();
let shared = Arc::new(Mutex::new(Reading::new(now)));
let worker_state = Arc::clone(&shared);
@@ -1063,11 +1081,14 @@ impl SpaceMouse {
let drivers = Arc::new(Drivers::default());
#[cfg(target_os = "linux")]
let worker_drivers = Arc::clone(&drivers);
// hidraw names no device's strings to compare (`Claim`).
#[cfg(target_os = "linux")]
let _ = bridge;
let spawned = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || {
#[cfg(target_os = "linux")]
reader(worker_ctx, worker_state, worker_drivers, Machine);
#[cfg(not(target_os = "linux"))]
reader(worker_ctx, worker_state);
reader(worker_ctx, worker_state, bridge);
});
if let Err(error) = spawned {
log::warn!("3D mouse: reader could not start: {error}");
@@ -1458,6 +1479,7 @@ mod tests {
},
layout,
ids: (0x256f, *product),
strings: Vec::new(),
})
.collect();
let (mut seen, mut read) = (Vec::new(), 0);
+67 -2
View File
@@ -8,8 +8,9 @@
//! row the next launch starts in safe mode by itself.
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
//! - **No usable graphics device**: one retry on the software renderer
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
//! - **A renderer that cannot start**: on Windows, try the remaining graphics APIs in order,
//! then the software renderer (`SOLIDCRAFT_RENDERER=cpu`). An explicit `WGPU_BACKEND` fails
//! cleanly without switching. Exhausted retries show a message box with the error.
use std::path::{Path, PathBuf};
@@ -218,6 +219,34 @@ pub fn choose_backends(
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
}
/// Internal, process-local retry choice; never written to the graphics preference.
pub const RETRY_BACKEND_ENV: &str = "SOLIDCRAFT_STARTUP_BACKEND";
/// Why this launch is using a fallback, carried to the new process for status and Preferences.
pub const FALLBACK_NOTE_ENV: &str = "SOLIDCRAFT_GRAPHICS_FALLBACK";
/// A handled startup failure tries each remaining Windows API once, then software rendering.
/// An explicit environment choice is final, including when software rendering was requested.
pub fn fallback_backend(windows: bool, explicit: bool, cpu: bool, failed: eframe::wgpu::Backends) -> Option<&'static str> {
if explicit || cpu {
return None;
}
if windows {
let last = WINDOWS_ORDER.iter().rposition(|backend| failed.intersects(*backend));
if let Some(next) = last.and_then(|i| WINDOWS_ORDER.get(i + 1)) {
return Some(if *next == eframe::wgpu::Backends::VULKAN { "vulkan" } else { "gl" });
}
}
Some("cpu")
}
/// Keep driver diagnostics on one bounded line, including across the finite retry chain.
pub fn fallback_note(previous: &str, failed: eframe::wgpu::Backends, next: &str, error: &str) -> String {
let previous: String = previous.chars().take(2048).collect();
let error: String = error.chars().take(1024).collect();
let line = format!("{previous} Graphics fallback: {failed:?} failed ({error}); trying {next}.");
line.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(4096).collect()
}
/// The device feature for a 32-bit float depth buffer with a stencil (reversed depth, #1434).
pub const FLOAT_DEPTH_STENCIL: eframe::wgpu::Features = eframe::wgpu::Features::DEPTH32FLOAT_STENCIL8;
@@ -442,6 +471,42 @@ mod tests {
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
}
#[test]
fn failed_windows_backends_fall_back_in_order_and_stop_after_software() {
use eframe::wgpu::Backends as B;
assert_eq!(fallback_backend(true, false, false, B::DX12), Some("vulkan"));
assert_eq!(fallback_backend(true, false, false, B::VULKAN), Some("gl"));
assert_eq!(fallback_backend(true, false, false, B::GL), Some("cpu"));
assert_eq!(fallback_backend(true, false, true, B::DX12), None);
assert_eq!(fallback_backend(true, false, false, B::DX12 | B::VULKAN), Some("gl"));
assert_eq!(fallback_backend(false, false, false, B::VULKAN), Some("cpu"));
let note = fallback_note("", B::DX12, "vulkan", "egui_pipeline\n0x80070057");
assert!(note.contains("egui_pipeline 0x80070057") && note.contains("vulkan") && !note.contains('\n'));
let long = fallback_note(&"x".repeat(5000), B::VULKAN, "gl", &"y".repeat(5000));
assert!(long.chars().count() <= 4096 && long.ends_with("trying gl."));
}
#[test]
fn explicit_backend_failure_never_switches_api_or_retries_software() {
use eframe::wgpu::Backends as B;
for windows in [false, true] {
for cpu in [false, true] {
for backend in [B::DX12, B::VULKAN, B::GL, B::all()] {
assert_eq!(fallback_backend(windows, true, cpu, backend), None);
let mut probed = false;
assert_eq!(
choose_backends(windows, true, cpu, Some("vulkan"), |_| {
probed = true;
false
}),
None
);
assert!(!probed);
}
}
}
}
#[test]
fn only_opengl_on_windows_gets_the_system_title_bar() {
use eframe::wgpu::Backends;
+1
View File
@@ -25,6 +25,7 @@ assets/
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
locales/{en,de,ja,ko}.json # shared offline catalogs
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
platforms/macos/dmg/ # disk image window: layout, dmgbuild settings, background art
archive/icons/macos-v1/
source/icon.vectorcraft # earlier editable work, retained
# redundant historical exports are retired
+4 -1
View File
@@ -270,7 +270,7 @@
"Preview Row": "Náhled řádku",
"Preview leaves the current design unchanged.": "Náhled aktuální návrh nemění.",
"Profiles/Curves": "Profily/křivky",
"Quantity": "Množství",
"Quantity": "Počet",
"RECENT": "NEDÁVNÉ",
"REFERENCE": "REFERENCE",
"RULED SURFACE": "PŘÍMKOVÁ PLOCHA",
@@ -974,6 +974,7 @@
"motion.key_here": "Vložit klíčový snímek sem",
"motion.key_here_hint": "Přidat v tomto kroku klíčový snímek s aktuální hodnotou kloubu",
"motion.no_collisions": "Během studie nedochází ke kolizím",
"motion.plot_quantity": "Množství",
"motion.step_back": "Krok zpět",
"motion.step_forward": "Krok vpřed",
"motion.to_end": "Na konec",
@@ -1556,6 +1557,7 @@
"ui.ai_http_busy": "endpoint je zaneprázdněný nebo překročil limit; zkuste to později",
"ui.ai_http_key": "endpoint odmítl klíč: zkontrolujte jej v Předvolby > AI",
"ui.ai_http_not_found": "takový endpoint nebo model neexistuje: zkontrolujte základní URL a název modelu",
"ui.ai_http_too_large": "konverzace je pro tento endpoint příliš velká; začněte nový chat",
"ui.ai_key": "API klíč",
"ui.ai_key_hint": "volitelné; uloženo v systémové klíčence",
"ui.ai_key_needs_https": "Klíč se odesílá pouze přes HTTPS nebo na tento počítač.",
@@ -1850,6 +1852,7 @@
"ui.heading_torus": "ANULOID",
"ui.heading_utilities": "NÁSTROJE",
"ui.heading_utility": "NÁSTROJE",
"ui.heading_weldments": "SVAŘENCE",
"ui.help": "Nápověda",
"ui.hide": "Skrýt",
"ui.hideAxes": "Skrýt osy",
+3
View File
@@ -1177,6 +1177,7 @@
"motion.key_here": "Sæt punkt her",
"motion.key_here_hint": "Tilføj et animationspunkt i dette trin med ledets aktuelle værdi",
"motion.no_collisions": "Ingen kollisioner under analysen",
"motion.plot_quantity": "Antal",
"motion.step_back": "Trin tilbage",
"motion.step_forward": "Trin frem",
"motion.to_end": "Til slutningen",
@@ -1737,6 +1738,7 @@
"ui.ai_http_busy": "endepunktet er optaget eller har nået sin grænse; prøv igen senere",
"ui.ai_http_key": "endepunktet afviste nøglen: Kontrollér den i Indstillinger > AI",
"ui.ai_http_not_found": "endepunktet eller modellen findes ikke: Kontrollér basis-URL og modelnavn",
"ui.ai_http_too_large": "samtalen er for stor til dette endpoint; start en ny chat",
"ui.ai_key": "API-nøgle",
"ui.ai_key_hint": "valgfri; gemmes i systemets nøglering",
"ui.ai_key_needs_https": "En nøgle sendes kun via HTTPS eller til denne computer.",
@@ -2039,6 +2041,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "VÆRKTØJER",
"ui.heading_utility": "VÆRKTØJ",
"ui.heading_weldments": "SVEJSNING",
"ui.help": "Hjælp",
"ui.hide": "Skjul",
"ui.hideAxes": "Skjul akser",
+6 -1
View File
@@ -338,7 +338,7 @@
"Profile / Edges": "Profil / Kanten",
"Profiles or curves": "Profile oder Kurven",
"Profiles/Curves": "Profile/Kurven",
"Quantity": "Größe",
"Quantity": "Anzahl",
"RECENT": "ZULETZT",
"REFERENCE": "REFERENZ",
"RULED SURFACE": "REGELFLÄCHE",
@@ -631,6 +631,7 @@
"cmd.document_metadata": "Konstruktionseigenschaften",
"cmd.document_metadata_remove": "Konstruktionseigenschaft entfernen",
"cmd.document_metadata_set": "Konstruktionseigenschaften ändern",
"cmd.document_revision": "Konstruktionsrevision",
"cmd.document_units": "Einheiten der Konstruktion ändern",
"cmd.edit_checkpoint": "Prüfpunkt",
"cmd.edit_restore_checkpoint": "Prüfpunkt wiederherstellen",
@@ -1104,6 +1105,7 @@
"motion.key_here": "Schlüsselbild hier setzen",
"motion.key_here_hint": "Ein Schlüsselbild bei diesem Schritt mit dem aktuellen Gelenkwert hinzufügen",
"motion.no_collisions": "Keine Kollisionen in der Untersuchung",
"motion.plot_quantity": "Größe",
"motion.step_back": "Schritt zurück",
"motion.step_forward": "Schritt vor",
"motion.to_end": "Zum Ende",
@@ -1116,6 +1118,7 @@
"nav.fusion.help": "Mittlere Maustaste verschiebt, Umschalt+Mitte dreht, rechte Maustaste dreht",
"nav.inventor.help": "Mittlere Maustaste verschiebt, Umschalt+Mitte dreht, Strg+Mitte zoomt, rechte Maustaste dreht",
"nav.openscad.help": "Linksziehen auf leerem Hintergrund dreht, Umschalt+Linksziehen zieht ein Auswahlrechteck auf, Skizzengeometrie wird durch Ziehen bearbeitet; rechtes oder mittleres Ziehen verschiebt, Umschalt+rechtes oder Umschalt+mittleres Ziehen zoomt",
"nav.perspective.sketch": "Beim Bearbeiten einer Skizze wird die orthografische Ansicht verwendet. Beenden Sie die Skizze, um die Perspektive umzuschalten.",
"nav.solidworks.help": "Mittlere Maustaste dreht, Strg+Mitte verschiebt, Umschalt+Mitte zoomt, rechte Maustaste dreht",
"nav.tinkercad.help": "Rechte Maustaste dreht, mittlere oder Umschalt+rechte Maustaste verschiebt",
"nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.",
@@ -1641,6 +1644,7 @@
"ui.ai_http_busy": "der Endpunkt ist ausgelastet oder am Limit; versuchen Sie es später",
"ui.ai_http_key": "der Endpunkt hat den Schlüssel abgelehnt: prüfen Sie ihn unter Einstellungen > KI",
"ui.ai_http_not_found": "Endpunkt oder Modell nicht gefunden: prüfen Sie Basis-URL und Modellnamen",
"ui.ai_http_too_large": "die Unterhaltung ist für diesen Endpunkt zu groß; beginnen Sie einen neuen Chat",
"ui.ai_key": "API-Schlüssel",
"ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert",
"ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.",
@@ -1937,6 +1941,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "WERKZEUGE",
"ui.heading_utility": "HILFSMITTEL",
"ui.heading_weldments": "SCHWEISSTEILE",
"ui.help": "Hilfe",
"ui.hide": "Ausblenden",
"ui.hideAxes": "Achsen ausblenden",
+88
View File
@@ -173,7 +173,12 @@
"Don't ask again": "Don't ask again",
"Double-click to rename": "Double-click to rename",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing",
"Drag to move the boss across the model. It snaps to sketch points on the face.": "Drag to move the boss across the model. It snaps to sketch points on the face.",
"Drag to move the hole; click to remove it": "Drag to move the hole; click to remove it",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Drag to move the rest across the model. It snaps to sketch points on the face.",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Drag to move the snap fit across the model. It snaps to sketch points on the face.",
"Drag to point the catch in another direction.": "Drag to point the catch in another direction.",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.",
"Drive Joint": "Drive Joint",
"Duration": "Duration",
"Edges": "Edges",
@@ -754,6 +759,7 @@
"cmd.document_metadata": "Design Properties",
"cmd.document_metadata_remove": "Remove Design Property",
"cmd.document_metadata_set": "Change Design Properties",
"cmd.document_revision": "Design Revision",
"cmd.document_units": "Change Design Units",
"cmd.edit_checkpoint": "Checkpoint",
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
@@ -919,6 +925,7 @@
"cmd.sheet_refold": "Refold",
"cmd.sheet_rules": "List Sheet Metal Rules",
"cmd.sheet_unfold": "Unfold",
"cmd.sketch_arc3d": "3D Arc",
"cmd.sketch_arc_center_point": "Center Point Arc",
"cmd.sketch_arc_tangent": "Tangent Arc",
"cmd.sketch_arc_three_point": "3-Point Arc",
@@ -1041,17 +1048,25 @@
"cmd.view_back": "Back View",
"cmd.view_bottom": "Bottom View",
"cmd.view_delete": "Delete Named View",
"cmd.view_fit": "Fit",
"cmd.view_front": "Front View",
"cmd.view_graphics_preset": "Graphics Preset",
"cmd.view_grid": "Grid",
"cmd.view_ground_shadow": "Ground shadow",
"cmd.view_home": "Home view",
"cmd.view_isometric": "Isometric View",
"cmd.view_left": "Left View",
"cmd.view_list": "List Named Views",
"cmd.view_navigate": "Navigate View",
"cmd.view_navigation_bar": "Navigation bar",
"cmd.view_normal_to": "Normal to Selection",
"cmd.view_rename": "Rename Named View",
"cmd.view_right": "Right View",
"cmd.view_save": "New Named View",
"cmd.view_spin": "Continuous orbit",
"cmd.view_tool": "Navigation tool",
"cmd.view_top": "Top View",
"cmd.view_visual_style": "Visual style",
"color.copy": "Click to copy color values",
"color.edit": "Click to edit color",
"color.gamma": "Showing color values in 0-255 gamma space",
@@ -1178,6 +1193,26 @@
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
"help.solid.sweep": "Drags a sketch profile along a path to make a solid. Use it for pipes, rails, handles and any part of constant section that follows a curve.",
"help.third_party": "Third-party licences",
"hole_presets.confirm_delete": "Confirm delete",
"hole_presets.delete": "Delete",
"hole_presets.delete_prompt": "Delete this personal preset? Existing holes are unchanged.",
"hole_presets.full": "The personal library already contains 64 presets.",
"hole_presets.invalid_name": "Enter a short name without line breaks or control characters.",
"hole_presets.invalid_parameters": "Complete the hole parameters using valid expressions of reasonable length.",
"hole_presets.missing": "The selected preset is no longer available.",
"hole_presets.name": "Preset name",
"hole_presets.name_exists": "A preset with this name already exists. Use a different name.",
"hole_presets.name_hint": "A unique name",
"hole_presets.overwrite": "Save changes to selected preset",
"hole_presets.preset": "Preset",
"hole_presets.recall": "Recall",
"hole_presets.rename": "Rename",
"hole_presets.save": "Save new",
"hole_presets.scope": "Saved in your profile for all projects.",
"hole_presets.select": "Select a preset",
"hole_presets.title": "Personal presets",
"hole_presets.unsupported_options": "This preset uses thread options that this version cannot recall.",
"hole_presets.unsupported_overwrite": "This preset contains options that cannot be overwritten safely.",
"home.back": "Back to {name}",
"home.crashes": "Crash reports",
"home.design_workspace": "Design",
@@ -1326,6 +1361,7 @@
"motion.key_here": "Key here",
"motion.key_here_hint": "Add a key at this step with the joint's value now",
"motion.no_collisions": "No collisions over the study",
"motion.plot_quantity": "Quantity",
"motion.step_back": "Step back",
"motion.step_forward": "Step on",
"motion.to_end": "To the end",
@@ -1338,6 +1374,7 @@
"nav.fusion.help": "Middle drag pans, Shift+middle orbits, right drag orbits",
"nav.inventor.help": "Middle drag pans, Shift+middle orbits, Ctrl+middle zooms, right drag orbits",
"nav.openscad.help": "Left drag on empty space orbits, Shift+left drag box-selects, sketch geometry drags edit; right or middle drag pans, Shift+right or Shift+middle drag zooms",
"nav.perspective.sketch": "Sketch editing uses orthographic view. Finish the sketch to switch perspective.",
"nav.solidworks.help": "Middle drag orbits, Ctrl+middle pans, Shift+middle zooms, right drag orbits",
"nav.tinkercad.help": "Right drag orbits, middle or Shift+right drag pans",
"nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
@@ -1347,6 +1384,10 @@
"nav.touchpad.scroll": "Touchpad uses vertical scroll to zoom. Your two-finger pan setting is kept for other presets.",
"nav.touchpad.zoom": "Zoom (vertical scroll or pinch)",
"needs two joints": "needs two joints",
"network.allow_once": "Allow once",
"network.always_allow": "Always allow this server",
"network.prompt_detail": "This design links to a file on network location {host}. Opening it connects to that computer and may send your Windows sign-in to it. Only allow servers you trust.",
"network.prompt_title": "Network location",
"new configuration": "new configuration",
"new parameter": "new parameter",
"no hole": "no hole",
@@ -1448,12 +1489,17 @@
"prefs.mouse_mapping": "Mouse mapping",
"prefs.navigation": "Navigation",
"prefs.navigation_note": "How the mouse, trackpad and 3D mouse move the view.",
"prefs.network_host_remove": "Remove",
"prefs.network_locations": "Allow network locations in designs",
"prefs.network_locations_note": "Off: a design's linked files on another computer (\\\\server\\share) are opened only from the servers listed here, or after you allow one when asked.",
"prefs.network_locations_section": "Network locations",
"prefs.not_on_gpu": "not on this GPU",
"prefs.on": "On",
"prefs.opaque": "Opaque",
"prefs.orbit_snap": "Hold Shift during orbit to snap to an axis.",
"prefs.orientation": "Orientation",
"prefs.overall_speed": "Overall speed",
"prefs.placement": "Placement",
"prefs.prefer_free_sketch_points": "Prefer movable sketch points when points overlap",
"prefs.preview_choices": "Preview of your choices",
"prefs.quality_default": "default",
@@ -1472,6 +1518,11 @@
"prefs.shift_add_ctrl_toggle": "Shift adds only; Ctrl / Cmd toggles",
"prefs.shift_add_note": "On: Shift adds, Ctrl / Cmd toggles. Off (default): both toggle.",
"prefs.shortcuts": "Shortcuts",
"prefs.sketch_exit_view": "When a sketch ends",
"prefs.sketch_exit_view.keep": "Keep the current view",
"prefs.sketch_exit_view.previous": "Return to the previous view",
"prefs.sketch_exit_view.unless_rotated": "Return unless rotated",
"prefs.sketch_exit_view_note": "Where the view goes after Finish Sketch, Cancel or a double Esc.",
"prefs.sketch_selection": "Sketch & selection",
"prefs.sketch_selection_heading": "Sketch and selection",
"prefs.sketch_selection_note": "How sketches snap and how clicks select.",
@@ -1498,6 +1549,8 @@
"prefs.units_precision": "Units and precision",
"prefs.updates": "Updates",
"prefs.view": "View behavior",
"prefs.view_confirm_corner": "OK and Cancel in the view's corner",
"prefs.view_confirm_corner_note": "While a dialog or a sketch is open, a check and a cross at the top right of the view do what the dialog's OK and Cancel do, or finish and cancel the sketch. The view navigator moves down below them.",
"prefs.viewport": "Viewport",
"prefs.wheel": "Wheel",
"prefs.wheel_trackpad": "Wheel and trackpad",
@@ -2185,12 +2238,18 @@
"the parts library is not available": "the parts library is not available",
"thread.automatic_size": "Automatic size",
"thread.designation": "Designation",
"thread.direction": "Thread direction",
"thread.iso_metric": "ISO metric",
"thread.left_hand": "Left-hand",
"thread.right_hand": "Right-hand",
"thread.select_size": "Select size",
"timeline.delete_after_marker_one": "Delete 1 Feature After History Marker",
"timeline.delete_after_marker_other": "Delete {count} Features After History Marker",
"timeline.delete_folders_tip": "Keep their features and nested folders in the history",
"timeline.delete_many": "Delete {count} Selected Features",
"timeline.feature_error": "Error: {message}",
"timeline.feature_hint": "Double-click to edit · drag to reorder · right-click for more",
"timeline.feature_warning": "Warning: {message}",
"timeline.group": "New Feature Folder",
"timeline.move_many": "Move Timeline Items",
"timeline.move_to_group": "Move Features to Folder",
@@ -2387,6 +2446,7 @@
"ui.agent_chat_key_in_env": "key set",
"ui.agent_chat_left": "Left",
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
"ui.agent_chat_login_found": "login found",
"ui.agent_chat_minimize": "Minimise to a strip",
"ui.agent_chat_mode_all": "All",
"ui.agent_chat_mode_ask": "Ask",
@@ -2489,6 +2549,7 @@
"ui.ai_http_busy": "the endpoint is busy or over its limit; try again later",
"ui.ai_http_key": "the endpoint refused the key: check it in Preferences > AI",
"ui.ai_http_not_found": "no such endpoint or model: check the base URL and the model name",
"ui.ai_http_too_large": "the conversation is too large for this endpoint; start a new chat",
"ui.ai_key": "API key",
"ui.ai_key_hint": "optional; kept in the system keychain",
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
@@ -2535,9 +2596,27 @@
"ui.ai_test_no_model": "Reached: {count} models, but not {model}",
"ui.ai_test_ok": "Reached: {count} models",
"ui.ai_testing": "Testing…",
"ui.ai_tool_arguments": "Arguments",
"ui.ai_tool_details": "Details",
"ui.ai_tool_done": "done",
"ui.ai_tool_failed": "failed",
"ui.ai_tool_result": "Result",
"ui.ai_tool_running": "running",
"ui.ai_tool_sum_bodies_one": "{count} body",
"ui.ai_tool_sum_bodies_other": "{count} bodies",
"ui.ai_tool_sum_by": "by {value}",
"ui.ai_tool_sum_edges_one": "{count} edge",
"ui.ai_tool_sum_edges_other": "{count} edges",
"ui.ai_tool_sum_faces_one": "{count} face",
"ui.ai_tool_sum_faces_other": "{count} faces",
"ui.ai_tool_sum_features_one": "{count} feature",
"ui.ai_tool_sum_features_other": "{count} features",
"ui.ai_tool_sum_measured": "Measured {what}",
"ui.ai_tool_sum_more": "{steps}, and {count} more",
"ui.ai_tool_sum_radius": "r {value}",
"ui.ai_tool_sum_saved": "Saved {file}",
"ui.ai_tool_sum_screenshot": "Screenshot",
"ui.ai_tool_sum_then": "{first}, then {next}",
"ui.ai_tool_unfold": "Click to show what it returned",
"ui.ai_tool_waiting": "waiting",
"ui.ai_toolbar_button": "Show Assistant button in application bar",
@@ -2797,6 +2876,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "UTILITIES",
"ui.heading_utility": "UTILITY",
"ui.heading_weldments": "WELDMENTS",
"ui.help": "Help",
"ui.hide": "Hide",
"ui.hideAxes": "Hide Axes",
@@ -2883,6 +2963,11 @@
"ui.move": "Move",
"ui.multiple": "Multiple",
"ui.named_views": "Named Views",
"ui.nav_bridge": "Navigate with a device app on this computer",
"ui.nav_bridge_connected": "Connected",
"ui.nav_bridge_port": "Port",
"ui.nav_bridge_tip": "A 3D input device's own program (for example a homemade 3D mouse's driver) moves the view over a local connection, so the mouse pointer stays yours. SolidCraft connects to it on this port on this computer only.",
"ui.nav_bridge_waiting": "Waiting for the device app",
"ui.navigation": "Navigation",
"ui.needs_design": "{command}: open a design first",
"ui.newSketchGroup": "New Sketch Group",
@@ -2932,6 +3017,8 @@
"ui.pinToToolbar": "Pin to Toolbar",
"ui.pipe": "Pipe",
"ui.placement": "Placement",
"ui.placement_handles": "Placement handles",
"ui.placement_handles_tip": "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.plugins": "Plugins",
@@ -3185,6 +3272,7 @@
"update.up_to_date": "SolidCraft is up to date.",
"update.update_now": "Update now",
"update.you_have": "You have {version}.",
"view.auto_ortho_faces": "Perspective while orbiting, ortho on faces",
"web.files.insert_drawings_hint": "CAD files, meshes, DXF or SVG into the open design",
"web.files.insert_stored_drawings_hint": "Into the open design (CAD files, meshes, DXF, SVG)",
"web.open_full": "Open in SolidCraft",
+7
View File
@@ -1057,6 +1057,7 @@
"motion.key_here": "Insertar fotograma clave aquí",
"motion.key_here_hint": "Añadir un fotograma clave en este paso con el valor actual de la unión",
"motion.no_collisions": "Sin colisiones durante el estudio",
"motion.plot_quantity": "Cantidad",
"motion.step_back": "Paso atrás",
"motion.step_forward": "Paso adelante",
"motion.to_end": "Al final",
@@ -1758,6 +1759,9 @@
"thread.designation": "Designación",
"thread.iso_metric": "Métrica ISO",
"thread.select_size": "Seleccionar tamaño",
"timeline.delete_folders_tip": "Conservar sus operaciones y carpetas anidadas en el historial",
"timeline.feature_error": "Error: {message}",
"timeline.feature_warning": "Advertencia: {message}",
"timeline.group": "Nueva carpeta de operaciones",
"timeline.move_many": "Mover elementos de la línea de tiempo",
"timeline.move_to_group": "Mover operaciones a la carpeta",
@@ -2037,6 +2041,7 @@
"ui.ai_http_busy": "el servicio está ocupado o ha superado su límite; inténtalo más tarde",
"ui.ai_http_key": "el servicio ha rechazado la clave: compruébala en Preferencias > IA",
"ui.ai_http_not_found": "no existe ese servicio o modelo: comprueba la URL base y el nombre del modelo",
"ui.ai_http_too_large": "la conversación es demasiado grande para este endpoint; empieza un chat nuevo",
"ui.ai_key": "Clave de API",
"ui.ai_key_hint": "opcional; se guarda en el llavero del sistema",
"ui.ai_key_needs_https": "La clave solo se envía mediante HTTPS o a este ordenador.",
@@ -2330,6 +2335,7 @@
"ui.heading_torus": "TORO",
"ui.heading_utilities": "UTILIDADES",
"ui.heading_utility": "UTILIDAD",
"ui.heading_weldments": "SOLDADURA",
"ui.help": "Ayuda",
"ui.hide": "Ocultar",
"ui.hideAxes": "Ocultar ejes",
@@ -2342,6 +2348,7 @@
"ui.history_first": "Ir al principio",
"ui.history_forward": "Avanzar un paso",
"ui.history_last": "Ir al final",
"ui.history_marker_tip": "Marcador del historial: arrastra para retroceder el modelo; las nuevas operaciones se insertan aquí",
"ui.history_title": "Historial",
"ui.hole": "Agujero",
"ui.hole_tap_type": "Tipo de roscado del agujero",
+4 -1
View File
@@ -270,7 +270,7 @@
"Preview Row": "Aperçu de la ligne",
"Preview leaves the current design unchanged.": "L'aperçu ne modifie pas la conception actuelle.",
"Profiles/Curves": "Profils/Courbes",
"Quantity": "Grandeur",
"Quantity": "Quantité",
"RECENT": "RÉCENTS",
"REFERENCE": "RÉFÉRENCE",
"RULED SURFACE": "SURFACE RÉGLÉE",
@@ -983,6 +983,7 @@
"motion.key_here": "Placer une image clé ici",
"motion.key_here_hint": "Ajouter une image clé à cette étape avec la valeur actuelle de la liaison",
"motion.no_collisions": "Aucune collision au cours de l'étude",
"motion.plot_quantity": "Grandeur",
"motion.step_back": "Étape précédente",
"motion.step_forward": "Étape suivante",
"motion.to_end": "À la fin",
@@ -1403,6 +1404,7 @@
"ui.ai_http_busy": "le point d'accès est occupé ou a dépassé sa limite ; réessayez plus tard",
"ui.ai_http_key": "le point d'accès a refusé la clé : vérifiez-la dans Préférences › IA",
"ui.ai_http_not_found": "point d'accès ou modèle introuvable : vérifiez l'URL de base et le nom du modèle",
"ui.ai_http_too_large": "la conversation est trop volumineuse pour ce point d'accès ; commencez une nouvelle discussion",
"ui.ai_key": "Clé d'API",
"ui.ai_key_hint": "facultative ; conservée dans le trousseau de clés du système",
"ui.ai_key_needs_https": "Une clé n'est envoyée qu'en HTTPS, ou vers cet ordinateur.",
@@ -1696,6 +1698,7 @@
"ui.heading_torus": "TORE",
"ui.heading_utilities": "UTILITAIRES",
"ui.heading_utility": "UTILITAIRE",
"ui.heading_weldments": "MÉCANO-SOUDURE",
"ui.help": "Aide",
"ui.hide": "Masquer",
"ui.hideAxes": "Masquer les axes",
+3
View File
@@ -971,6 +971,7 @@
"motion.key_here": "Inserir fotograma clave aquí",
"motion.key_here_hint": "Engadir un fotograma clave neste paso co valor actual da unión",
"motion.no_collisions": "Sen colisións durante o estudo",
"motion.plot_quantity": "Cantidade",
"motion.step_back": "Paso atrás",
"motion.step_forward": "Paso adiante",
"motion.to_end": "Ao final",
@@ -1389,6 +1390,7 @@
"ui.ai_http_busy": "o punto de acceso está ocupado ou superou o seu límite; téntao de novo máis tarde",
"ui.ai_http_key": "o punto de acceso rexeitou a clave: compróbaa en Preferencias > IA",
"ui.ai_http_not_found": "non existe ese punto de acceso ou modelo: comproba o URL base e o nome do modelo",
"ui.ai_http_too_large": "a conversa é demasiado grande para este punto de acceso; comeza un chat novo",
"ui.ai_key": "Clave da API",
"ui.ai_key_hint": "opcional; gárdase no chaveiro do sistema",
"ui.ai_key_needs_https": "A clave só se envía por HTTPS ou a este ordenador.",
@@ -1682,6 +1684,7 @@
"ui.heading_torus": "TORO",
"ui.heading_utilities": "UTILIDADES",
"ui.heading_utility": "UTILIDADE",
"ui.heading_weldments": "SOLDADURA",
"ui.help": "Axuda",
"ui.hide": "Ocultar",
"ui.hideAxes": "Ocultar os eixes",
+3
View File
@@ -970,6 +970,7 @@
"motion.key_here": "Kulcs ide",
"motion.key_here_hint": "Kulcs hozzáadása ennél a lépésnél a csukló jelenlegi értékével",
"motion.no_collisions": "Nincs ütközés a vizsgálat során",
"motion.plot_quantity": "Mennyiség",
"motion.step_back": "Lépés vissza",
"motion.step_forward": "Lépés tovább",
"motion.to_end": "A végére",
@@ -1332,6 +1333,7 @@
"ui.ai_http_busy": "a végpont foglalt vagy túllépte a korlátját; próbálja később",
"ui.ai_http_key": "a végpont elutasította a kulcsot: ellenőrizze a Beállítások > AI menüben",
"ui.ai_http_not_found": "nincs ilyen végpont vagy modell: ellenőrizze az alap-URL-t és a modell nevét",
"ui.ai_http_too_large": "a beszélgetés túl nagy ehhez a végponthoz; kezdjen új csevegést",
"ui.ai_key": "API-kulcs",
"ui.ai_key_hint": "opcionális; a rendszer kulcstartójában tárolódik",
"ui.ai_key_needs_https": "A kulcs csak HTTPS-en vagy erre a számítógépre kerül elküldésre.",
@@ -1624,6 +1626,7 @@
"ui.heading_torus": "TÓRUSZ",
"ui.heading_utilities": "SEGÉDESZKÖZÖK",
"ui.heading_utility": "SEGÉDESZKÖZ",
"ui.heading_weldments": "HEGESZTÉS",
"ui.help": "Súgó",
"ui.hide": "Elrejtés",
"ui.hideAxes": "Tengelyek elrejtése",
+3
View File
@@ -970,6 +970,7 @@
"motion.key_here": "Key di sini",
"motion.key_here_hint": "Tambah key pada langkah ini dengan nilai sambungan saat ini",
"motion.no_collisions": "Tidak ada tabrakan sepanjang studi",
"motion.plot_quantity": "Jumlah",
"motion.step_back": "Mundur satu langkah",
"motion.step_forward": "Maju satu langkah",
"motion.to_end": "Ke akhir",
@@ -1332,6 +1333,7 @@
"ui.ai_http_busy": "endpoint sibuk atau melebihi batas; coba lagi nanti",
"ui.ai_http_key": "endpoint menolak key: periksa di Preferensi > AI",
"ui.ai_http_not_found": "endpoint atau model tidak ada: periksa URL dasar dan nama model",
"ui.ai_http_too_large": "percakapan terlalu besar untuk endpoint ini; mulai obrolan baru",
"ui.ai_key": "Key API",
"ui.ai_key_hint": "opsional; disimpan di keychain sistem",
"ui.ai_key_needs_https": "Key hanya dikirim lewat HTTPS, atau ke komputer ini.",
@@ -1624,6 +1626,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "UTILITAS",
"ui.heading_utility": "UTILITAS",
"ui.heading_weldments": "KONSTRUKSI LAS",
"ui.help": "Bantuan",
"ui.hide": "Sembunyikan",
"ui.hideAxes": "Sembunyikan Sumbu",
+26
View File
@@ -873,6 +873,26 @@
"help.mit": "Licenza MIT",
"help.notice": "NOTICE",
"help.third_party": "Licenze di terze parti",
"hole_presets.confirm_delete": "Conferma eliminazione",
"hole_presets.delete": "Elimina",
"hole_presets.delete_prompt": "Eliminare questo preset personale? I fori esistenti restano invariati.",
"hole_presets.full": "La raccolta personale contiene già 64 preset.",
"hole_presets.invalid_name": "Inserisci un nome breve, senza a capo o caratteri di controllo.",
"hole_presets.invalid_parameters": "Completa i parametri del foro con espressioni valide e di lunghezza ragionevole.",
"hole_presets.missing": "Il preset selezionato non è più disponibile.",
"hole_presets.name": "Nome preset",
"hole_presets.name_exists": "Esiste già un preset con questo nome. Usa un nome diverso.",
"hole_presets.name_hint": "Un nome univoco",
"hole_presets.overwrite": "Salva le modifiche nel preset selezionato",
"hole_presets.preset": "Preset",
"hole_presets.recall": "Richiama",
"hole_presets.rename": "Rinomina",
"hole_presets.save": "Salva nuovo",
"hole_presets.scope": "Salvati nel tuo profilo per tutti i progetti.",
"hole_presets.select": "Seleziona un preset",
"hole_presets.title": "Preset personali",
"hole_presets.unsupported_options": "Questo preset usa opzioni del filetto che questa versione non può richiamare.",
"hole_presets.unsupported_overwrite": "Questo preset contiene opzioni che non possono essere sovrascritte in sicurezza.",
"home.back": "Torna a {name}",
"home.crashes": "Rapporti di arresto anomalo",
"home.design_workspace": "Progettazione",
@@ -976,6 +996,7 @@
"motion.key_here": "Inserisci fotogramma chiave qui",
"motion.key_here_hint": "Aggiungi un fotogramma chiave in questo passo con il valore attuale del giunto",
"motion.no_collisions": "Nessuna collisione durante lo studio",
"motion.plot_quantity": "Quantità",
"motion.step_back": "Passo indietro",
"motion.step_forward": "Passo avanti",
"motion.to_end": "Alla fine",
@@ -1130,7 +1151,10 @@
"the parts library is not available": "la libreria delle parti non è disponibile",
"thread.automatic_size": "Dimensione automatica",
"thread.designation": "Designazione",
"thread.direction": "Verso del filetto",
"thread.iso_metric": "Metrica ISO",
"thread.left_hand": "Sinistro",
"thread.right_hand": "Destro",
"thread.select_size": "Seleziona dimensione",
"timeline.group": "Nuova cartella di lavorazioni",
"timeline.move_many": "Sposta elementi della sequenza temporale",
@@ -1405,6 +1429,7 @@
"ui.ai_http_busy": "l'endpoint è occupato o ha superato il limite; riprova più tardi",
"ui.ai_http_key": "l'endpoint ha rifiutato la chiave: controllala in Preferenze > IA",
"ui.ai_http_not_found": "endpoint o modello inesistente: controlla l'URL di base e il nome del modello",
"ui.ai_http_too_large": "la conversazione è troppo grande per questo endpoint; inizia una nuova chat",
"ui.ai_key": "Chiave API",
"ui.ai_key_hint": "facoltativa; conservata nel portachiavi di sistema",
"ui.ai_key_needs_https": "La chiave viene inviata solo tramite HTTPS o a questo computer.",
@@ -1699,6 +1724,7 @@
"ui.heading_torus": "TORO",
"ui.heading_utilities": "UTILITÀ",
"ui.heading_utility": "UTILITÀ",
"ui.heading_weldments": "SALDATURE",
"ui.help": "Aiuto",
"ui.hide": "Nascondi",
"ui.hideAxes": "Nascondi assi",
+89 -1
View File
@@ -173,7 +173,12 @@
"Don't ask again": "今後確認しない",
"Double-click to rename": "ダブルクリックで名前変更",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "ビュー上で中ボタンをダブルクリックした時の動作: ホーム(全体を収めたホームビュー)、全体表示(向きを維持して全体を収める)、または無効",
"Drag to move the boss across the model. It snaps to sketch points on the face.": "ドラッグしてボスをモデル上で移動します。面上のスケッチ点にスナップします。",
"Drag to move the hole; click to remove it": "ドラッグで穴を移動、クリックで削除",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "ドラッグしてレストをモデル上で移動します。面上のスケッチ点にスナップします。",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "ドラッグしてスナップ フィットをモデル上で移動します。面上のスケッチ点にスナップします。",
"Drag to point the catch in another direction.": "ドラッグしてキャッチの向きを変えます。",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "ドラッグしてレストを 5° 刻みで回転します。Alt または Ctrl を押したままにすると自由に回転します。",
"Drive Joint": "ジョイントを駆動",
"Duration": "時間",
"Edges": "エッジ",
@@ -398,7 +403,7 @@
"Profile / Edges": "プロファイル / エッジ",
"Profiles or curves": "プロファイルまたは曲線",
"Profiles/Curves": "プロファイル/曲線",
"Quantity": "量",
"Quantity": "数量",
"RECENT": "最近使用した項目",
"REFERENCE": "参照",
"RULED SURFACE": "ルールドサーフェス",
@@ -754,6 +759,7 @@
"cmd.document_metadata": "デザインのプロパティ",
"cmd.document_metadata_remove": "デザインのプロパティを削除",
"cmd.document_metadata_set": "デザインのプロパティを変更",
"cmd.document_revision": "デザイン リビジョン",
"cmd.document_units": "デザインの単位を変更",
"cmd.edit_checkpoint": "チェックポイント",
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
@@ -919,6 +925,7 @@
"cmd.sheet_refold": "折り戻し",
"cmd.sheet_rules": "板金ルールを一覧表示",
"cmd.sheet_unfold": "展開",
"cmd.sketch_arc3d": "3D 円弧",
"cmd.sketch_arc_center_point": "中心点円弧",
"cmd.sketch_arc_tangent": "正接円弧",
"cmd.sketch_arc_three_point": "3点指定円弧",
@@ -1041,17 +1048,25 @@
"cmd.view_back": "背面ビュー",
"cmd.view_bottom": "下面ビュー",
"cmd.view_delete": "名前付きビューを削除",
"cmd.view_fit": "フィット",
"cmd.view_front": "正面ビュー",
"cmd.view_graphics_preset": "グラフィックスプリセット",
"cmd.view_grid": "グリッド",
"cmd.view_ground_shadow": "地面の影",
"cmd.view_home": "ホームビュー",
"cmd.view_isometric": "等角ビュー",
"cmd.view_left": "左側面ビュー",
"cmd.view_list": "名前付きビューを一覧表示",
"cmd.view_navigate": "ビューナビゲーション",
"cmd.view_navigation_bar": "ナビゲーションバー",
"cmd.view_normal_to": "選択アイテムに垂直",
"cmd.view_rename": "名前付きビューの名前を変更",
"cmd.view_right": "右側面ビュー",
"cmd.view_save": "新規名前付きビュー",
"cmd.view_spin": "連続オービット",
"cmd.view_tool": "ナビゲーションツール",
"cmd.view_top": "上面ビュー",
"cmd.view_visual_style": "表示スタイル",
"color.copy": "クリックして色の値をコピー",
"color.edit": "クリックして色を編集",
"color.gamma": "0〜255のガンマ空間で色の値を表示",
@@ -1178,6 +1193,26 @@
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
"help.solid.sweep": "スケッチの輪郭をパスに沿って動かし、立体を作ります。パイプ、レール、ハンドルなど、一定の断面で曲線をたどる部品に使います。",
"help.third_party": "サードパーティライセンス",
"hole_presets.confirm_delete": "削除を確認",
"hole_presets.delete": "削除",
"hole_presets.delete_prompt": "この個人用プリセットを削除しますか?既存の穴は変更されません。",
"hole_presets.full": "個人用ライブラリには既に64個のプリセットがあります。",
"hole_presets.invalid_name": "改行や制御文字を含まない短い名前を入力してください。",
"hole_presets.invalid_parameters": "適切な長さの有効な式で穴のパラメーターを入力してください。",
"hole_presets.missing": "選択したプリセットは利用できなくなりました。",
"hole_presets.name": "プリセット名",
"hole_presets.name_exists": "同じ名前のプリセットが既にあります。別の名前を指定してください。",
"hole_presets.name_hint": "一意の名前",
"hole_presets.overwrite": "選択したプリセットの変更を保存",
"hole_presets.preset": "プリセット",
"hole_presets.recall": "呼び出し",
"hole_presets.rename": "名前を変更",
"hole_presets.save": "新規保存",
"hole_presets.scope": "すべてのプロジェクトで使えるようにプロファイルに保存します。",
"hole_presets.select": "プリセットを選択",
"hole_presets.title": "個人用プリセット",
"hole_presets.unsupported_options": "このプリセットには、このバージョンで呼び出せないねじオプションが含まれています。",
"hole_presets.unsupported_overwrite": "このプリセットには安全に上書きできないオプションが含まれています。",
"home.back": "{name} に戻る",
"home.crashes": "クラッシュレポート",
"home.design_workspace": "デザイン",
@@ -1326,6 +1361,7 @@
"motion.key_here": "ここにキーフレームを追加",
"motion.key_here_hint": "このステップにジョイントの現在値でキーフレームを追加",
"motion.no_collisions": "スタディ全体で衝突はありません",
"motion.plot_quantity": "量",
"motion.step_back": "前のステップ",
"motion.step_forward": "次のステップ",
"motion.to_end": "最後へ",
@@ -1338,6 +1374,7 @@
"nav.fusion.help": "中ドラッグでパン、Shift+中ドラッグで回転、右ドラッグで回転。",
"nav.inventor.help": "中ドラッグでパン、Shift+中ドラッグで回転、Ctrl+中ドラッグでズーム、右ドラッグで回転。",
"nav.openscad.help": "空きスペースで左ドラッグすると回転、Shift+左ドラッグで矩形選択、スケッチ ジオメトリはドラッグで編集。右または中ドラッグでパン、Shift+右または Shift+中ドラッグでズーム。",
"nav.perspective.sketch": "スケッチ編集中は平行投影で表示されます。透視投影に切り替えるにはスケッチを終了してください。",
"nav.solidworks.help": "中ドラッグで回転、Ctrl+中ドラッグでパン、Shift+中ドラッグでズーム、右ドラッグで回転。",
"nav.tinkercad.help": "右ドラッグで回転、中ドラッグまたは Shift+右ドラッグでパン。",
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
@@ -1347,6 +1384,10 @@
"nav.touchpad.scroll": "タッチパッドでは縦スクロールでズームします。2 本指パンの設定は他のプリセット用に保持されます。",
"nav.touchpad.zoom": "ズーム(縦スクロールまたはピンチ)",
"needs two joints": "2つのジョイントが必要",
"network.allow_once": "今回のみ許可",
"network.always_allow": "このサーバーを常に許可",
"network.prompt_detail": "このデザインはネットワーク上の場所 {host} にあるファイルにリンクしています。開くとそのコンピューターに接続し、Windows のサインイン情報が送信される場合があります。信頼できるサーバーだけを許可してください。",
"network.prompt_title": "ネットワーク上の場所",
"new configuration": "新規構成",
"new parameter": "新規パラメータ",
"no hole": "穴なし",
@@ -1448,12 +1489,17 @@
"prefs.mouse_mapping": "マウスの割り当て",
"prefs.navigation": "ナビゲーション",
"prefs.navigation_note": "マウス、トラックパッド、3Dマウスによるビューの操作。",
"prefs.network_host_remove": "削除",
"prefs.network_locations": "デザイン内のネットワーク上の場所を許可",
"prefs.network_locations_note": "オフ: 別のコンピューター (\\\\server\\share) にあるデザインのリンク先ファイルは、ここに表示されたサーバー、または確認時に許可したサーバーからのみ開きます。",
"prefs.network_locations_section": "ネットワーク上の場所",
"prefs.not_on_gpu": "この GPU は非対応",
"prefs.on": "オン",
"prefs.opaque": "不透明",
"prefs.orbit_snap": "回転中にShiftを押すと軸にスナップします。",
"prefs.orientation": "方向",
"prefs.overall_speed": "全体の速度",
"prefs.placement": "配置",
"prefs.prefer_free_sketch_points": "スケッチ点が重なったときは移動可能な点を優先する",
"prefs.preview_choices": "選択した設定のプレビュー",
"prefs.quality_default": "既定",
@@ -1472,6 +1518,11 @@
"prefs.shift_add_ctrl_toggle": "Shift は追加のみ、Ctrl / Cmd は選択を切り替え",
"prefs.shift_add_note": "オン:Shift は追加、Ctrl / Cmd は選択を切り替えます。オフ(既定):どちらも選択を切り替えます。",
"prefs.shortcuts": "ショートカット",
"prefs.sketch_exit_view": "スケッチ終了時のビュー",
"prefs.sketch_exit_view.keep": "現在のビューを維持",
"prefs.sketch_exit_view.previous": "前のビューに戻る",
"prefs.sketch_exit_view.unless_rotated": "回転していなければ戻る",
"prefs.sketch_exit_view_note": "「スケッチを終了」、キャンセル、Esc の 2 回押しの後にビューが移る先です。",
"prefs.sketch_selection": "スケッチと選択",
"prefs.sketch_selection_heading": "スケッチと選択",
"prefs.sketch_selection_note": "スケッチのスナップとクリックによる選択。",
@@ -1498,6 +1549,8 @@
"prefs.units_precision": "単位と精度",
"prefs.updates": "更新",
"prefs.view": "ビューの動作",
"prefs.view_confirm_corner": "ビューの隅に OK とキャンセル",
"prefs.view_confirm_corner_note": "ダイアログまたはスケッチを開いている間、ビュー右上のチェックとバツが、ダイアログの OK とキャンセルと同じ操作を行うか、スケッチを終了またはキャンセルします。ビューナビゲーターはその下に移動します。",
"prefs.viewport": "ビューポート",
"prefs.wheel": "ホイール",
"prefs.wheel_trackpad": "ホイールとトラックパッド",
@@ -2185,12 +2238,18 @@
"the parts library is not available": "パーツライブラリは利用できません",
"thread.automatic_size": "サイズを自動選択",
"thread.designation": "ねじの呼び",
"thread.direction": "ねじの方向",
"thread.iso_metric": "ISOメートルねじ",
"thread.left_hand": "左ねじ",
"thread.right_hand": "右ねじ",
"thread.select_size": "サイズを選択",
"timeline.delete_after_marker_one": "履歴マーカー以降の 1 個のフィーチャーを削除",
"timeline.delete_after_marker_other": "履歴マーカー以降の {count} 個のフィーチャーを削除",
"timeline.delete_folders_tip": "フィーチャーと入れ子のフォルダーを履歴に残します",
"timeline.delete_many": "選択した {count} 個のフィーチャーを削除",
"timeline.feature_error": "エラー: {message}",
"timeline.feature_hint": "ダブルクリックで編集 · ドラッグで並べ替え · 右クリックでその他の操作",
"timeline.feature_warning": "警告: {message}",
"timeline.group": "フィーチャーフォルダを新規作成",
"timeline.move_many": "タイムライン項目を移動",
"timeline.move_to_group": "フィーチャーをフォルダへ移動",
@@ -2387,6 +2446,7 @@
"ui.agent_chat_key_in_env": "キー設定済み",
"ui.agent_chat_left": "左",
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
"ui.agent_chat_login_found": "ログイン情報あり",
"ui.agent_chat_minimize": "細いバーに最小化",
"ui.agent_chat_mode_all": "すべて",
"ui.agent_chat_mode_ask": "確認",
@@ -2489,6 +2549,7 @@
"ui.ai_http_busy": "エンドポイントが混雑しているか上限に達しています。後で再試行してください",
"ui.ai_http_key": "エンドポイントがキーを拒否しました。「環境設定 > AI」で確認してください",
"ui.ai_http_not_found": "エンドポイントまたはモデルが見つかりません。ベース URL とモデル名を確認してください",
"ui.ai_http_too_large": "会話がこのエンドポイントには大きすぎます。新しいチャットを始めてください",
"ui.ai_key": "API キー",
"ui.ai_key_hint": "任意。システムのキーチェーンに保存",
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
@@ -2535,9 +2596,27 @@
"ui.ai_test_no_model": "接続成功: {count} 個のモデル({model} はありません)",
"ui.ai_test_ok": "接続成功: {count} 個のモデル",
"ui.ai_testing": "テスト中…",
"ui.ai_tool_arguments": "引数",
"ui.ai_tool_details": "詳細",
"ui.ai_tool_done": "完了",
"ui.ai_tool_failed": "失敗",
"ui.ai_tool_result": "結果",
"ui.ai_tool_running": "実行中",
"ui.ai_tool_sum_bodies_one": "{count} ボディ",
"ui.ai_tool_sum_bodies_other": "{count} ボディ",
"ui.ai_tool_sum_by": "距離 {value}",
"ui.ai_tool_sum_edges_one": "{count} エッジ",
"ui.ai_tool_sum_edges_other": "{count} エッジ",
"ui.ai_tool_sum_faces_one": "{count} 面",
"ui.ai_tool_sum_faces_other": "{count} 面",
"ui.ai_tool_sum_features_one": "{count} フィーチャー",
"ui.ai_tool_sum_features_other": "{count} フィーチャー",
"ui.ai_tool_sum_measured": "測定: {what}",
"ui.ai_tool_sum_more": "{steps}、ほか {count} 件",
"ui.ai_tool_sum_radius": "R {value}",
"ui.ai_tool_sum_saved": "{file} を保存しました",
"ui.ai_tool_sum_screenshot": "スクリーンショット",
"ui.ai_tool_sum_then": "{first}、続いて {next}",
"ui.ai_tool_unfold": "クリックして結果を表示",
"ui.ai_tool_waiting": "待機中",
"ui.ai_toolbar_button": "アプリケーションバーにアシスタントボタンを表示",
@@ -2797,6 +2876,7 @@
"ui.heading_torus": "トーラス",
"ui.heading_utilities": "ユーティリティ",
"ui.heading_utility": "ユーティリティ",
"ui.heading_weldments": "溶接構造",
"ui.help": "ヘルプ",
"ui.hide": "非表示",
"ui.hideAxes": "軸を非表示",
@@ -2883,6 +2963,11 @@
"ui.move": "移動",
"ui.multiple": "複数",
"ui.named_views": "名前付きビュー",
"ui.nav_bridge": "このコンピューター上のデバイスアプリで操作",
"ui.nav_bridge_connected": "接続済み",
"ui.nav_bridge_port": "ポート",
"ui.nav_bridge_tip": "3D 入力デバイス専用のプログラム(自作 3D マウスのドライバーなど)がローカル接続でビューを動かすので、マウスポインターはそのまま使えます。SolidCraft はこのコンピューター上のこのポートにだけ接続します。",
"ui.nav_bridge_waiting": "デバイスアプリを待機中",
"ui.navigation": "ナビゲーション",
"ui.needs_design": "{command}: 先にデザインを開いてください",
"ui.newSketchGroup": "新しいスケッチグループ",
@@ -2932,6 +3017,8 @@
"ui.pinToToolbar": "ツールバーに固定",
"ui.pipe": "パイプ",
"ui.placement": "配置",
"ui.placement_handles": "配置ハンドル",
"ui.placement_handles_tip": "レスト、ボス、スナップ フィットのプレビューに表示されるハンドル: マーカーをドラッグしてモデル上を移動し、リングで回転します",
"ui.plane": "平面",
"ui.plane_at_angle": "傾斜平面",
"ui.plugins": "プラグイン",
@@ -3185,6 +3272,7 @@
"update.up_to_date": "SolidCraft は最新です。",
"update.update_now": "今すぐ更新",
"update.you_have": "現在のバージョンは {version} です。",
"view.auto_ortho_faces": "回転中は透視投影、面を正面に見ると平行投影",
"web.files.insert_drawings_hint": "CADファイル、メッシュ、DXF、SVGを開いているデザインに挿入",
"web.files.insert_stored_drawings_hint": "開いているデザインに挿入(CADファイル、メッシュ、DXF、SVG)",
"web.open_full": "SolidCraft で開く",
+7 -1
View File
@@ -348,7 +348,7 @@
"Profile / Edges": "프로파일 / 모서리",
"Profiles or curves": "프로파일 또는 곡선",
"Profiles/Curves": "프로파일/곡선",
"Quantity": "양",
"Quantity": "수량",
"RECENT": "최근",
"REFERENCE": "참조",
"RULED SURFACE": "룰드 서피스",
@@ -1151,6 +1151,7 @@
"motion.key_here": "여기에 키프레임 추가",
"motion.key_here_hint": "이 단계에 조인트의 현재 값으로 키프레임 추가",
"motion.no_collisions": "연구 전체에서 충돌이 없습니다",
"motion.plot_quantity": "양",
"motion.step_back": "이전 단계",
"motion.step_forward": "다음 단계",
"motion.to_end": "끝으로",
@@ -1163,6 +1164,7 @@
"nav.fusion.help": "가운데 드래그는 이동, Shift+가운데 드래그는 회전, 오른쪽 드래그는 회전입니다",
"nav.inventor.help": "가운데 드래그는 이동, Shift+가운데 드래그는 회전, Ctrl+가운데 드래그는 확대/축소, 오른쪽 드래그는 회전입니다",
"nav.openscad.help": "빈 공간에서 왼쪽 드래그는 회전, Shift+왼쪽 드래그는 상자 선택, 스케치 형상 드래그는 편집입니다. 오른쪽 또는 가운데 드래그는 이동, Shift+오른쪽 또는 Shift+가운데 드래그는 확대/축소입니다",
"nav.perspective.sketch": "스케치 편집 중에는 직교 뷰를 사용합니다. 원근 뷰를 전환하려면 스케치를 마치세요.",
"nav.solidworks.help": "가운데 드래그는 회전, Ctrl+가운데 드래그는 이동, Shift+가운데 드래그는 확대/축소, 오른쪽 드래그는 회전입니다",
"nav.tinkercad.help": "오른쪽 드래그는 회전, 가운데 또는 Shift+오른쪽 드래그는 이동입니다",
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
@@ -1306,6 +1308,8 @@
"prefs.units_precision": "단위 및 정밀도",
"prefs.updates": "업데이트",
"prefs.view": "뷰 동작",
"prefs.view_confirm_corner": "뷰 모서리에 확인 및 취소",
"prefs.view_confirm_corner_note": "대화 상자나 스케치가 열려 있는 동안 뷰 오른쪽 위의 체크와 X 표시가 대화 상자의 확인과 취소와 같은 동작을 하거나 스케치를 마치고 취소합니다. 뷰 탐색기는 그 아래로 내려갑니다.",
"prefs.viewport": "뷰포트",
"prefs.wheel": "휠",
"prefs.wheel_trackpad": "휠 및 트랙패드",
@@ -1810,6 +1814,7 @@
"ui.ai_http_busy": "엔드포인트가 바쁘거나 한도에 도달했습니다. 나중에 다시 시도하세요",
"ui.ai_http_key": "엔드포인트가 키를 거부했습니다. 환경 설정 > AI에서 확인하세요",
"ui.ai_http_not_found": "엔드포인트나 모델을 찾을 수 없습니다. 기본 URL과 모델 이름을 확인하세요",
"ui.ai_http_too_large": "대화가 이 엔드포인트에 비해 너무 큽니다. 새 채팅을 시작하세요",
"ui.ai_key": "API 키",
"ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장",
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
@@ -2103,6 +2108,7 @@
"ui.heading_torus": "토러스",
"ui.heading_utilities": "유틸리티",
"ui.heading_utility": "유틸리티",
"ui.heading_weldments": "용접 구조물",
"ui.help": "도움말",
"ui.hide": "숨기기",
"ui.hideAxes": "축 숨기기",
+3
View File
@@ -971,6 +971,7 @@
"motion.key_here": "Kunci di sini",
"motion.key_here_hint": "Tambah kekunci pada langkah ini dengan nilai sendi sekarang",
"motion.no_collisions": "Tiada perlanggaran sepanjang kajian",
"motion.plot_quantity": "Kuantiti",
"motion.step_back": "Langkah ke belakang",
"motion.step_forward": "Langkah ke hadapan",
"motion.to_end": "Ke penghujung",
@@ -1389,6 +1390,7 @@
"ui.ai_http_busy": "titik akhir sibuk atau telah melebihi had; cuba lagi kemudian",
"ui.ai_http_key": "kunci ditolak oleh titik akhir: semak dalam Tetapan > AI",
"ui.ai_http_not_found": "titik akhir atau model tidak ditemui: semak URL asas dan nama model",
"ui.ai_http_too_large": "perbualan terlalu besar untuk titik akhir ini; mulakan sembang baharu",
"ui.ai_key": "Kunci API",
"ui.ai_key_hint": "tidak wajib; disimpan dalam rantai kunci sistem",
"ui.ai_key_needs_https": "Kunci hanya dihantar melalui HTTPS, atau ke komputer ini.",
@@ -1682,6 +1684,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "UTILITI",
"ui.heading_utility": "UTILITI",
"ui.heading_weldments": "KIMPALAN",
"ui.help": "Bantuan",
"ui.hide": "Sembunyi",
"ui.hideAxes": "Sembunyi Paksi",
+3
View File
@@ -970,6 +970,7 @@
"motion.key_here": "Sleutel hier",
"motion.key_here_hint": "Voeg een sleutel toe op deze stap met de huidige waarde van de verbinding",
"motion.no_collisions": "Geen botsingen in de studie",
"motion.plot_quantity": "Aantal",
"motion.step_back": "Stap terug",
"motion.step_forward": "Stap verder",
"motion.to_end": "Naar het einde",
@@ -1332,6 +1333,7 @@
"ui.ai_http_busy": "het eindpunt is bezet of boven zijn limiet; probeer het later opnieuw",
"ui.ai_http_key": "het eindpunt weigerde de sleutel: controleer deze in Voorkeuren > AI",
"ui.ai_http_not_found": "geen eindpunt of model gevonden: controleer de basis-URL en de modelnaam",
"ui.ai_http_too_large": "het gesprek is te groot voor dit endpoint; begin een nieuwe chat",
"ui.ai_key": "API-sleutel",
"ui.ai_key_hint": "optioneel; bewaard in de sleutelhanger van het systeem",
"ui.ai_key_needs_https": "Een sleutel wordt alleen via HTTPS of naar deze computer verzonden.",
@@ -1624,6 +1626,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "HULPPROGRAMMA'S",
"ui.heading_utility": "HULPPROGRAMMA",
"ui.heading_weldments": "LASWERK",
"ui.help": "Help",
"ui.hide": "Verbergen",
"ui.hideAxes": "Assen verbergen",
+17
View File
@@ -124,6 +124,11 @@
"Don't ask again": "Nie pytaj ponownie",
"Double-click to rename": "Kliknij dwukrotnie, aby zmienić nazwę",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Dwukrotne kliknięcie środkowym przyciskiem w widoku: Widok główny (kadruje to, co widoczne), Dopasuj (kadruje to, co widoczne, zachowując orientację) lub nic",
"Drag to move the boss across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć występ po modelu. Przyciąga się do punktów szkicu na ścianie.",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć podparcie po modelu. Przyciąga się do punktów szkicu na ścianie.",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "Przeciągnij, aby przesunąć zatrzask po modelu. Przyciąga się do punktów szkicu na ścianie.",
"Drag to point the catch in another direction.": "Przeciągnij, aby skierować zaczep w inną stronę.",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "Przeciągnij, aby obrócić podparcie co 5°. Przytrzymaj Alt lub Ctrl, aby obracać swobodnie.",
"Drive Joint": "Steruj złączem",
"Duration": "Czas trwania",
"Edges": "Krawędzie",
@@ -568,6 +573,7 @@
"cmd.document_metadata": "Właściwości projektu",
"cmd.document_metadata_remove": "Usuń właściwość projektu",
"cmd.document_metadata_set": "Zmień właściwości projektu",
"cmd.document_revision": "Wersja projektu",
"cmd.document_units": "Zmień jednostki projektu",
"cmd.edit_checkpoint": "Punkt kontrolny",
"cmd.edit_restore_checkpoint": "Przywróć punkt kontrolny",
@@ -1026,6 +1032,7 @@
"motion.key_here": "Wstaw klatkę kluczową tutaj",
"motion.key_here_hint": "Dodaj klatkę kluczową w tym kroku z bieżącą wartością złącza",
"motion.no_collisions": "Brak kolizji w trakcie badania",
"motion.plot_quantity": "Liczba",
"motion.step_back": "Krok wstecz",
"motion.step_forward": "Krok naprzód",
"motion.to_end": "Na koniec",
@@ -1131,6 +1138,7 @@
"prefs.orbit_snap": "Przytrzymaj Shift podczas obracania widoku, aby przyciągnąć do osi.",
"prefs.orientation": "Orientacja",
"prefs.overall_speed": "Szybkość ogólna",
"prefs.placement": "Umieszczanie",
"prefs.preview_choices": "Podgląd wybranych opcji",
"prefs.quality_default": "domyślnie",
"prefs.quality_fastest": "najszybciej",
@@ -1575,6 +1583,7 @@
"ui.ai_http_busy": "punkt końcowy jest zajęty lub przekroczył limit; spróbuj ponownie później",
"ui.ai_http_key": "punkt końcowy odrzucił klucz: sprawdź go w Preferencje > AI",
"ui.ai_http_not_found": "nie ma takiego punktu końcowego lub modelu: sprawdź bazowy URL i nazwę modelu",
"ui.ai_http_too_large": "rozmowa jest za duża dla tego punktu końcowego; rozpocznij nowy czat",
"ui.ai_key": "Klucz API",
"ui.ai_key_hint": "opcjonalny; przechowywany w systemowym pęku kluczy",
"ui.ai_key_needs_https": "Klucz jest wysyłany tylko przez HTTPS albo do tego komputera.",
@@ -1868,6 +1877,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "NARZĘDZIA",
"ui.heading_utility": "NARZĘDZIE",
"ui.heading_weldments": "SPAWANIE",
"ui.help": "Pomoc",
"ui.hide": "Ukryj",
"ui.hideAxes": "Ukryj osie",
@@ -1947,6 +1957,11 @@
"ui.move": "Przesuń",
"ui.multiple": "Wiele",
"ui.named_views": "Widoki nazwane",
"ui.nav_bridge": "Nawiguj programem urządzenia na tym komputerze",
"ui.nav_bridge_connected": "Połączono",
"ui.nav_bridge_port": "Port",
"ui.nav_bridge_tip": "Własny program urządzenia wejściowego 3D (np. sterownik domowej myszy 3D) porusza widokiem przez połączenie lokalne, więc kursor myszy zostaje Twój. SolidCraft łączy się z nim na tym porcie, tylko na tym komputerze.",
"ui.nav_bridge_waiting": "Czekam na program urządzenia",
"ui.navigation": "Nawigacja",
"ui.needs_design": "{command}: najpierw otwórz projekt",
"ui.newSketchGroup": "Nowa grupa szkiców",
@@ -1990,6 +2005,8 @@
"ui.pinToToolbar": "Przypnij do paska narzędzi",
"ui.pipe": "Rura",
"ui.placement": "Umiejscowienie",
"ui.placement_handles": "Uchwyty umieszczania",
"ui.placement_handles_tip": "Uchwyty na podglądzie podparcia, występu lub zatrzasku: przeciągnij znacznik, aby przesunąć element po modelu, obróć go pierścieniem",
"ui.plane": "Płaszczyzna",
"ui.plane_at_angle": "Płaszczyzna pod kątem",
"ui.plugins": "Wtyczki",
+3
View File
@@ -1145,6 +1145,7 @@
"motion.key_here": "Inserir quadro-chave aqui",
"motion.key_here_hint": "Adicionar um quadro-chave neste passo com o valor atual da junta",
"motion.no_collisions": "Sem colisões durante o estudo",
"motion.plot_quantity": "Quantidade",
"motion.step_back": "Passo anterior",
"motion.step_forward": "Próximo passo",
"motion.to_end": "Para o fim",
@@ -1704,6 +1705,7 @@
"ui.ai_http_busy": "o endpoint está ocupado ou acima do limite; tente de novo mais tarde",
"ui.ai_http_key": "o endpoint recusou a chave: verifique-a em Preferências > IA",
"ui.ai_http_not_found": "endpoint ou modelo inexistente: verifique a URL base e o nome do modelo",
"ui.ai_http_too_large": "a conversa é grande demais para este endpoint; comece um novo chat",
"ui.ai_key": "Chave de API",
"ui.ai_key_hint": "opcional; guardada no chaveiro do sistema",
"ui.ai_key_needs_https": "Uma chave só é enviada por HTTPS ou para este computador.",
@@ -2004,6 +2006,7 @@
"ui.heading_torus": "TORO",
"ui.heading_utilities": "UTILITÁRIOS",
"ui.heading_utility": "UTILITÁRIO",
"ui.heading_weldments": "SOLDAGEM",
"ui.help": "Ajuda",
"ui.hide": "Ocultar",
"ui.hideAxes": "Ocultar eixos",
+3
View File
@@ -970,6 +970,7 @@
"motion.key_here": "Cheie aici",
"motion.key_here_hint": "Adaugă o cheie la acest pas cu valoarea curentă a îmbinării",
"motion.no_collisions": "Nicio coliziune în studiu",
"motion.plot_quantity": "Cantitate",
"motion.step_back": "Pas înapoi",
"motion.step_forward": "Pas înainte",
"motion.to_end": "La sfârșit",
@@ -1332,6 +1333,7 @@
"ui.ai_http_busy": "endpointul este ocupat sau și-a depășit limita; încercați mai târziu",
"ui.ai_http_key": "endpointul a refuzat cheia: verificați-o în Preferințe > AI",
"ui.ai_http_not_found": "nu există un astfel de endpoint sau model: verificați URL-ul de bază și numele modelului",
"ui.ai_http_too_large": "conversația este prea mare pentru acest endpoint; începeți un chat nou",
"ui.ai_key": "Cheie API",
"ui.ai_key_hint": "opțional; păstrată în depozitul de chei al sistemului",
"ui.ai_key_needs_https": "O cheie se trimite doar prin HTTPS sau către acest calculator.",
@@ -1624,6 +1626,7 @@
"ui.heading_torus": "TOR",
"ui.heading_utilities": "UTILITARE",
"ui.heading_utility": "UTILITAR",
"ui.heading_weldments": "SUDURĂ",
"ui.help": "Ajutor",
"ui.hide": "Ascunde",
"ui.hideAxes": "Ascunde axele",
+4 -1
View File
@@ -269,7 +269,7 @@
"Preview Row": "Строка предпросмотра",
"Preview leaves the current design unchanged.": "Предпросмотр не меняет текущий дизайн.",
"Profiles/Curves": "Профили/кривые",
"Quantity": "Величина",
"Quantity": "Количество",
"RECENT": "НЕДАВНИЕ",
"REFERENCE": "ССЫЛОЧНАЯ ГЕОМЕТРИЯ",
"RULED SURFACE": "ЛИНЕЙЧАТАЯ ПОВЕРХНОСТЬ",
@@ -970,6 +970,7 @@
"motion.key_here": "Вставить ключевой кадр здесь",
"motion.key_here_hint": "Добавить ключевой кадр на этом шаге с текущим значением сопряжения",
"motion.no_collisions": "За всё исследование столкновений нет",
"motion.plot_quantity": "Величина",
"motion.step_back": "Шаг назад",
"motion.step_forward": "Шаг вперёд",
"motion.to_end": "В конец",
@@ -1388,6 +1389,7 @@
"ui.ai_http_busy": "конечная точка занята или превысила лимит; повторите позже",
"ui.ai_http_key": "конечная точка отклонила ключ: проверьте его в Настройки > ИИ",
"ui.ai_http_not_found": "нет такой конечной точки или модели: проверьте базовый URL и имя модели",
"ui.ai_http_too_large": "разговор слишком велик для этой конечной точки; начните новый чат",
"ui.ai_key": "Ключ API",
"ui.ai_key_hint": "необязательно; хранится в системной связке ключей",
"ui.ai_key_needs_https": "Ключ отправляется только по HTTPS или на этот компьютер.",
@@ -1681,6 +1683,7 @@
"ui.heading_torus": "ТОР",
"ui.heading_utilities": "УТИЛИТЫ",
"ui.heading_utility": "УТИЛИТА",
"ui.heading_weldments": "СВАРКА",
"ui.help": "Справка",
"ui.hide": "Скрыть",
"ui.hideAxes": "Скрыть оси",
+3
View File
@@ -970,6 +970,7 @@
"motion.key_here": "Nyckel här",
"motion.key_here_hint": "Lägg till en nyckel vid detta steg med ledens nuvarande värde",
"motion.no_collisions": "Inga kollisioner under studien",
"motion.plot_quantity": "Antal",
"motion.step_back": "Steg bakåt",
"motion.step_forward": "Steg framåt",
"motion.to_end": "Till slutet",
@@ -1332,6 +1333,7 @@
"ui.ai_http_busy": "slutpunkten är upptagen eller över sin gräns; försök igen senare",
"ui.ai_http_key": "slutpunkten nekade nyckeln: kontrollera den under Inställningar > AI",
"ui.ai_http_not_found": "ingen sådan slutpunkt eller modell: kontrollera bas-URL:en och modellnamnet",
"ui.ai_http_too_large": "konversationen är för stor för den här slutpunkten; starta en ny chatt",
"ui.ai_key": "API-nyckel",
"ui.ai_key_hint": "valfritt; lagras i systemets nyckelring",
"ui.ai_key_needs_https": "En nyckel skickas endast via HTTPS, eller till den här datorn.",
@@ -1624,6 +1626,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "VERKTYG",
"ui.heading_utility": "VERKTYG",
"ui.heading_weldments": "SVETSNING",
"ui.help": "Hjälp",
"ui.hide": "Dölj",
"ui.hideAxes": "Dölj axlar",
+4 -1
View File
@@ -269,7 +269,7 @@
"Preview Row": "Satırı önizle",
"Preview leaves the current design unchanged.": "Önizleme geçerli tasarımı değiştirmez.",
"Profiles/Curves": "Profiller/Eğriler",
"Quantity": "Büyüklük",
"Quantity": "Adet",
"RECENT": "SON KULLANILANLAR",
"REFERENCE": "REFERANS",
"RULED SURFACE": "REGLE YÜZEY",
@@ -970,6 +970,7 @@
"motion.key_here": "Buraya anahtar kare ekle",
"motion.key_here_hint": "Bu adıma bağlantının mevcut değeriyle bir anahtar kare ekle",
"motion.no_collisions": "Etüt boyunca çarpışma yok",
"motion.plot_quantity": "Büyüklük",
"motion.step_back": "Bir adım geri",
"motion.step_forward": "Bir adım ileri",
"motion.to_end": "Sona git",
@@ -1388,6 +1389,7 @@
"ui.ai_http_busy": "uç nokta meşgul veya sınırda; daha sonra tekrar deneyin",
"ui.ai_http_key": "uç nokta anahtarı reddetti: Ayarlar > AI altında kontrol edin",
"ui.ai_http_not_found": "Uç nokta veya model bulunamadı: temel URL'yi ve model adını kontrol edin",
"ui.ai_http_too_large": "konuşma bu uç nokta için çok büyük; yeni bir sohbet başlatın",
"ui.ai_key": "API anahtarı",
"ui.ai_key_hint": "isteğe bağlı; sistem anahtar zincirinde saklanır",
"ui.ai_key_needs_https": "Anahtar yalnızca HTTPS üzerinden veya bu bilgisayara gönderilir.",
@@ -1681,6 +1683,7 @@
"ui.heading_torus": "TORUS",
"ui.heading_utilities": "ARAÇLAR",
"ui.heading_utility": "YARDIMCI ARAÇLAR",
"ui.heading_weldments": "KAYNAK",
"ui.help": "Yardım",
"ui.hide": "Gizle",
"ui.hideAxes": "Eksenleri gizle",
+4 -1
View File
@@ -349,7 +349,7 @@
"Profile / Edges": "Профіль / ребра",
"Profiles or curves": "Профілі або криві",
"Profiles/Curves": "Профілі/криві",
"Quantity": "Величина",
"Quantity": "Кількість",
"RECENT": "НЕДАВНІ",
"REFERENCE": "ДОПОМІЖНА ГЕОМЕТРІЯ",
"RULED SURFACE": "ЛІНІЙЧАСТА ПОВЕРХНЯ",
@@ -1133,6 +1133,7 @@
"motion.key_here": "Вставити ключовий кадр тут",
"motion.key_here_hint": "Додати ключовий кадр на цьому кроці з поточним значенням з’єднання",
"motion.no_collisions": "За все дослідження зіткнень немає",
"motion.plot_quantity": "Величина",
"motion.step_back": "Крок назад",
"motion.step_forward": "Крок уперед",
"motion.to_end": "У кінець",
@@ -1684,6 +1685,7 @@
"ui.ai_http_busy": "кінцева точка зайнята або перевищила ліміт; спробуйте пізніше",
"ui.ai_http_key": "кінцева точка відхилила ключ: перевірте його в Налаштування > ШІ",
"ui.ai_http_not_found": "немає такої кінцевої точки чи моделі: перевірте базову URL-адресу й назву моделі",
"ui.ai_http_too_large": "розмова завелика для цієї кінцевої точки; почніть новий чат",
"ui.ai_key": "Ключ API",
"ui.ai_key_hint": "необов'язково; зберігається в системному сховищі ключів",
"ui.ai_key_needs_https": "Ключ надсилається лише через HTTPS або на цей комп'ютер.",
@@ -1977,6 +1979,7 @@
"ui.heading_torus": "ТОР",
"ui.heading_utilities": "УТИЛІТИ",
"ui.heading_utility": "УТИЛІТА",
"ui.heading_weldments": "ЗВАРЮВАННЯ",
"ui.help": "Довідка",
"ui.hide": "Приховати",
"ui.hideAxes": "Приховати осі",
+3
View File
@@ -971,6 +971,7 @@
"motion.key_here": "Đặt khóa tại đây",
"motion.key_here_hint": "Thêm một khóa tại bước này với giá trị hiện tại của khớp nối",
"motion.no_collisions": "Không có va chạm trong suốt quá trình khảo sát",
"motion.plot_quantity": "Số lượng",
"motion.step_back": "Lùi một bước",
"motion.step_forward": "Tiến một bước",
"motion.to_end": "Đến cuối",
@@ -1389,6 +1390,7 @@
"ui.ai_http_busy": "điểm cuối đang bận hoặc vượt quá giới hạn; vui lòng thử lại sau",
"ui.ai_http_key": "điểm cuối từ chối khóa API: hãy kiểm tra trong Tùy chọn cài đặt > AI",
"ui.ai_http_not_found": "không tìm thấy điểm cuối hoặc mô hình: hãy kiểm tra URL cơ sở và tên mô hình",
"ui.ai_http_too_large": "cuộc trò chuyện quá lớn đối với điểm cuối này; hãy bắt đầu cuộc trò chuyện mới",
"ui.ai_key": "Khóa API",
"ui.ai_key_hint": "tùy chọn; lưu trữ bảo mật trong chuỗi khóa hệ thống",
"ui.ai_key_needs_https": "Khóa chỉ được gửi qua HTTPS, hoặc trực tiếp trên máy tính này.",
@@ -1682,6 +1684,7 @@
"ui.heading_torus": "HÌNH XUYẾN",
"ui.heading_utilities": "TIỆN ÍCH",
"ui.heading_utility": "TIỆN ÍCH",
"ui.heading_weldments": "KẾT CẤU HÀN",
"ui.help": "Trợ giúp",
"ui.hide": "Ẩn",
"ui.hideAxes": "Ẩn Các trục",
+3
View File
@@ -970,6 +970,7 @@
"motion.key_here": "在此建立關鍵影格",
"motion.key_here_hint": "在此步驟新增關鍵影格,使用接頭目前的值",
"motion.no_collisions": "整個研究中沒有碰撞",
"motion.plot_quantity": "數量",
"motion.step_back": "上一步",
"motion.step_forward": "下一步",
"motion.to_end": "到結尾",
@@ -1332,6 +1333,7 @@
"ui.ai_http_busy": "端點繁忙或已達上限,請稍後再試",
"ui.ai_http_key": "端點拒絕了金鑰:請在「偏好設定 > AI」中檢查",
"ui.ai_http_not_found": "找不到該端點或模型:請檢查基礎 URL 和模型名稱",
"ui.ai_http_too_large": "對話對此端點來說過大;請開始新的聊天",
"ui.ai_key": "API 金鑰",
"ui.ai_key_hint": "可選;儲存在系統鑰匙圈中",
"ui.ai_key_needs_https": "金鑰只會透過 HTTPS 傳送,或傳送到本機。",
@@ -1624,6 +1626,7 @@
"ui.heading_torus": "圓環體",
"ui.heading_utilities": "工具",
"ui.heading_utility": "實用工具",
"ui.heading_weldments": "焊件",
"ui.help": "說明",
"ui.hide": "隱藏",
"ui.hideAxes": "隱藏軸",
+89 -1
View File
@@ -173,7 +173,12 @@
"Don't ask again": "不再询问",
"Double-click to rename": "双击重命名",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "在视图中双击中键:主视图(主视图,框住所显示的内容)、适合窗口(框住所显示的内容,保持方向)或无动作",
"Drag to move the boss across the model. It snaps to sketch points on the face.": "拖动以在模型上移动凸台。它会吸附到面上的草图点。",
"Drag to move the hole; click to remove it": "拖动以移动孔,单击以删除",
"Drag to move the rest across the model. It snaps to sketch points on the face.": "拖动以在模型上移动垫块。它会吸附到面上的草图点。",
"Drag to move the snap fit across the model. It snaps to sketch points on the face.": "拖动以在模型上移动卡扣。它会吸附到面上的草图点。",
"Drag to point the catch in another direction.": "拖动以将卡钩指向另一个方向。",
"Drag to turn the rest in 5° steps. Hold Alt or Ctrl to turn it freely.": "拖动以 5° 为步长旋转垫块。按住 Alt 或 Ctrl 可自由旋转。",
"Drive Joint": "驱动接头",
"Duration": "时长",
"Edges": "边",
@@ -398,7 +403,7 @@
"Profile / Edges": "轮廓 / 边",
"Profiles or curves": "轮廓或曲线",
"Profiles/Curves": "轮廓/曲线",
"Quantity": "物理量",
"Quantity": "数量",
"RECENT": "最近使用",
"REFERENCE": "参考",
"RULED SURFACE": "直纹曲面",
@@ -754,6 +759,7 @@
"cmd.document_metadata": "设计属性",
"cmd.document_metadata_remove": "删除设计属性",
"cmd.document_metadata_set": "更改设计属性",
"cmd.document_revision": "设计版本号",
"cmd.document_units": "更改设计单位",
"cmd.edit_checkpoint": "检查点",
"cmd.edit_restore_checkpoint": "恢复检查点",
@@ -919,6 +925,7 @@
"cmd.sheet_refold": "重新折弯",
"cmd.sheet_rules": "列出钣金规则",
"cmd.sheet_unfold": "展开",
"cmd.sketch_arc3d": "三维圆弧",
"cmd.sketch_arc_center_point": "中心点圆弧",
"cmd.sketch_arc_tangent": "相切圆弧",
"cmd.sketch_arc_three_point": "三点圆弧",
@@ -1041,17 +1048,25 @@
"cmd.view_back": "后视图",
"cmd.view_bottom": "仰视图",
"cmd.view_delete": "删除命名视图",
"cmd.view_fit": "适合视图",
"cmd.view_front": "前视图",
"cmd.view_graphics_preset": "图形预设",
"cmd.view_grid": "网格",
"cmd.view_ground_shadow": "地面阴影",
"cmd.view_home": "主视图",
"cmd.view_isometric": "等轴测视图",
"cmd.view_left": "左视图",
"cmd.view_list": "列出命名视图",
"cmd.view_navigate": "导航视图",
"cmd.view_navigation_bar": "导航栏",
"cmd.view_normal_to": "垂直于所选面",
"cmd.view_rename": "重命名命名视图",
"cmd.view_right": "右视图",
"cmd.view_save": "新建命名视图",
"cmd.view_spin": "连续环绕",
"cmd.view_tool": "导航工具",
"cmd.view_top": "俯视图",
"cmd.view_visual_style": "视觉样式",
"color.copy": "单击复制颜色值",
"color.edit": "单击编辑颜色",
"color.gamma": "以 0–255 伽马空间显示颜色值",
@@ -1178,6 +1193,26 @@
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
"help.solid.sweep": "沿路径拖动草图轮廓生成实体。用于管道、导轨、手柄等截面恒定且沿曲线延伸的零件。",
"help.third_party": "第三方许可证",
"hole_presets.confirm_delete": "确认删除",
"hole_presets.delete": "删除",
"hole_presets.delete_prompt": "删除此个人预设?现有孔不会改变。",
"hole_presets.full": "个人库中已有64个预设。",
"hole_presets.invalid_name": "请输入不含换行符或控制字符的简短名称。",
"hole_presets.invalid_parameters": "请使用长度适当的有效表达式填写孔参数。",
"hole_presets.missing": "所选预设已不可用。",
"hole_presets.name": "预设名称",
"hole_presets.name_exists": "同名预设已存在,请使用其他名称。",
"hole_presets.name_hint": "唯一名称",
"hole_presets.overwrite": "保存对所选预设的修改",
"hole_presets.preset": "预设",
"hole_presets.recall": "调用",
"hole_presets.rename": "重命名",
"hole_presets.save": "另存为新预设",
"hole_presets.scope": "保存在您的配置中,适用于所有项目。",
"hole_presets.select": "选择预设",
"hole_presets.title": "个人预设",
"hole_presets.unsupported_options": "此预设使用了当前版本无法调用的螺纹选项。",
"hole_presets.unsupported_overwrite": "此预设包含无法安全覆盖的选项。",
"home.back": "返回 {name}",
"home.crashes": "崩溃报告",
"home.design_workspace": "设计",
@@ -1326,6 +1361,7 @@
"motion.key_here": "在此添加关键帧",
"motion.key_here_hint": "在此步骤添加关键帧,使用关节的当前值",
"motion.no_collisions": "整个研究中没有碰撞",
"motion.plot_quantity": "物理量",
"motion.step_back": "上一步",
"motion.step_forward": "下一步",
"motion.to_end": "到结尾",
@@ -1338,6 +1374,7 @@
"nav.fusion.help": "中键拖动平移,Shift+中键拖动旋转,右键拖动旋转",
"nav.inventor.help": "中键拖动平移,Shift+中键拖动旋转,Ctrl+中键拖动缩放,右键拖动旋转",
"nav.openscad.help": "在空白处左键拖动旋转,Shift+左键拖动框选,拖动草图几何可编辑;右键或中键拖动平移,Shift+右键或 Shift+中键拖动缩放",
"nav.perspective.sketch": "编辑草图时使用正交视图。完成草图后才能切换透视。",
"nav.solidworks.help": "中键拖动旋转,Ctrl+中键拖动平移,Shift+中键拖动缩放,右键拖动旋转",
"nav.tinkercad.help": "右键拖动旋转,中键或 Shift+右键拖动平移",
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
@@ -1347,6 +1384,10 @@
"nav.touchpad.scroll": "触控板使用垂直滚动进行缩放。双指平移设置将保留供其他预设使用。",
"nav.touchpad.zoom": "缩放(垂直滚动或双指捏合)",
"needs two joints": "需要两个接头",
"network.allow_once": "仅允许一次",
"network.always_allow": "始终允许此服务器",
"network.prompt_detail": "此设计链接到网络位置 {host} 上的文件。打开它会连接到该计算机,并可能向其发送你的 Windows 登录信息。只允许你信任的服务器。",
"network.prompt_title": "网络位置",
"new configuration": "新配置",
"new parameter": "新参数",
"no hole": "无孔",
@@ -1448,12 +1489,17 @@
"prefs.mouse_mapping": "鼠标映射",
"prefs.navigation": "导航",
"prefs.navigation_note": "鼠标、触控板和3D鼠标如何移动视图。",
"prefs.network_host_remove": "删除",
"prefs.network_locations": "允许设计中的网络位置",
"prefs.network_locations_note": "关闭:设计中位于其他计算机 (\\\\server\\share) 上的链接文件,只会从此处列出的服务器打开,或在询问时你允许的服务器打开。",
"prefs.network_locations_section": "网络位置",
"prefs.not_on_gpu": "此 GPU 不支持",
"prefs.on": "开启",
"prefs.opaque": "不透明",
"prefs.orbit_snap": "旋转时按住Shift可捕捉到坐标轴。",
"prefs.orientation": "方向",
"prefs.overall_speed": "整体速度",
"prefs.placement": "放置",
"prefs.prefer_free_sketch_points": "草图点重合时优先选择可移动的点",
"prefs.preview_choices": "所选设置预览",
"prefs.quality_default": "默认",
@@ -1472,6 +1518,11 @@
"prefs.shift_add_ctrl_toggle": "Shift 仅添加;Ctrl / Cmd 切换选择",
"prefs.shift_add_note": "开启:Shift 添加,Ctrl / Cmd 切换选择。关闭(默认):两者均切换选择。",
"prefs.shortcuts": "快捷键",
"prefs.sketch_exit_view": "草图结束时的视图",
"prefs.sketch_exit_view.keep": "保持当前视图",
"prefs.sketch_exit_view.previous": "返回之前的视图",
"prefs.sketch_exit_view.unless_rotated": "未旋转时返回",
"prefs.sketch_exit_view_note": "“完成草图”、取消或连按两次 Esc 后视图的去向。",
"prefs.sketch_selection": "草图与选择",
"prefs.sketch_selection_heading": "草图和选择",
"prefs.sketch_selection_note": "草图如何捕捉,以及单击如何选择。",
@@ -1498,6 +1549,8 @@
"prefs.units_precision": "单位与精度",
"prefs.updates": "更新",
"prefs.view": "视图行为",
"prefs.view_confirm_corner": "在视图角落显示确定和取消",
"prefs.view_confirm_corner_note": "打开对话框或草图时,视图右上角的勾号和叉号与对话框的确定和取消作用相同,或完成和取消草图。视图导航器会下移到它们下方。",
"prefs.viewport": "视口",
"prefs.wheel": "滚轮",
"prefs.wheel_trackpad": "滚轮与触控板",
@@ -2185,12 +2238,18 @@
"the parts library is not available": "零件库不可用",
"thread.automatic_size": "自动尺寸",
"thread.designation": "螺纹标记",
"thread.direction": "螺纹方向",
"thread.iso_metric": "ISO 公制",
"thread.left_hand": "左旋",
"thread.right_hand": "右旋",
"thread.select_size": "选择尺寸",
"timeline.delete_after_marker_one": "删除历史标记之后的 1 个特征",
"timeline.delete_after_marker_other": "删除历史标记之后的 {count} 个特征",
"timeline.delete_folders_tip": "在历史记录中保留其中的特征和嵌套文件夹",
"timeline.delete_many": "删除所选的 {count} 个特征",
"timeline.feature_error": "错误:{message}",
"timeline.feature_hint": "双击编辑 · 拖动以重新排序 · 右键查看更多操作",
"timeline.feature_warning": "警告:{message}",
"timeline.group": "新建特征文件夹",
"timeline.move_many": "移动时间线项目",
"timeline.move_to_group": "将特征移至文件夹",
@@ -2387,6 +2446,7 @@
"ui.agent_chat_key_in_env": "已设置密钥",
"ui.agent_chat_left": "左侧",
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
"ui.agent_chat_login_found": "已找到登录信息",
"ui.agent_chat_minimize": "最小化为细条",
"ui.agent_chat_mode_all": "全部",
"ui.agent_chat_mode_ask": "询问",
@@ -2489,6 +2549,7 @@
"ui.ai_http_busy": "端点繁忙或已达上限,请稍后再试",
"ui.ai_http_key": "端点拒绝了密钥:请在“首选项 > AI”中检查",
"ui.ai_http_not_found": "找不到该端点或模型:请检查基础 URL 和模型名称",
"ui.ai_http_too_large": "对话对此端点来说过大,请开始新的聊天",
"ui.ai_key": "API 密钥",
"ui.ai_key_hint": "可选;保存在系统钥匙串中",
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
@@ -2535,9 +2596,27 @@
"ui.ai_test_no_model": "已连接:{count} 个模型,但没有 {model}",
"ui.ai_test_ok": "已连接:{count} 个模型",
"ui.ai_testing": "正在测试…",
"ui.ai_tool_arguments": "参数",
"ui.ai_tool_details": "详细信息",
"ui.ai_tool_done": "完成",
"ui.ai_tool_failed": "失败",
"ui.ai_tool_result": "结果",
"ui.ai_tool_running": "运行中",
"ui.ai_tool_sum_bodies_one": "{count} 个实体",
"ui.ai_tool_sum_bodies_other": "{count} 个实体",
"ui.ai_tool_sum_by": "距离 {value}",
"ui.ai_tool_sum_edges_one": "{count} 条边",
"ui.ai_tool_sum_edges_other": "{count} 条边",
"ui.ai_tool_sum_faces_one": "{count} 个面",
"ui.ai_tool_sum_faces_other": "{count} 个面",
"ui.ai_tool_sum_features_one": "{count} 个特征",
"ui.ai_tool_sum_features_other": "{count} 个特征",
"ui.ai_tool_sum_measured": "已测量 {what}",
"ui.ai_tool_sum_more": "{steps},另有 {count} 项",
"ui.ai_tool_sum_radius": "R {value}",
"ui.ai_tool_sum_saved": "已保存 {file}",
"ui.ai_tool_sum_screenshot": "截图",
"ui.ai_tool_sum_then": "{first},然后 {next}",
"ui.ai_tool_unfold": "点击显示返回内容",
"ui.ai_tool_waiting": "等待中",
"ui.ai_toolbar_button": "在工具栏显示按钮",
@@ -2797,6 +2876,7 @@
"ui.heading_torus": "圆环体",
"ui.heading_utilities": "工具",
"ui.heading_utility": "实用工具",
"ui.heading_weldments": "焊件",
"ui.help": "帮助",
"ui.hide": "隐藏",
"ui.hideAxes": "隐藏轴",
@@ -2883,6 +2963,11 @@
"ui.move": "移动",
"ui.multiple": "多个",
"ui.named_views": "命名视图",
"ui.nav_bridge": "使用本机上的设备程序导航",
"ui.nav_bridge_connected": "已连接",
"ui.nav_bridge_port": "端口",
"ui.nav_bridge_tip": "3D 输入设备自带的程序(例如自制 3D 鼠标的驱动程序)通过本地连接移动视图,鼠标指针仍由你使用。SolidCraft 只在本机的此端口上连接它。",
"ui.nav_bridge_waiting": "正在等待设备程序",
"ui.navigation": "导航",
"ui.needs_design": "{command}: 请先打开一个设计",
"ui.newSketchGroup": "新建草图组",
@@ -2932,6 +3017,8 @@
"ui.pinToToolbar": "固定到工具栏",
"ui.pipe": "管道",
"ui.placement": "放置",
"ui.placement_handles": "放置手柄",
"ui.placement_handles_tip": "垫块、凸台或卡扣预览上的手柄:拖动标记在模型上移动,用转环旋转",
"ui.plane": "平面",
"ui.plane_at_angle": "角度平面",
"ui.plugins": "插件",
@@ -3185,6 +3272,7 @@
"update.up_to_date": "SolidCraft 已是最新版本。",
"update.update_now": "立即更新",
"update.you_have": "当前版本为 {version}。",
"view.auto_ortho_faces": "旋转时透视,正对表面时正交",
"web.files.insert_drawings_hint": "将 CAD 文件、网格、DXF 或 SVG 插入当前设计",
"web.files.insert_stored_drawings_hint": "插入当前设计(CAD 文件、网格、DXF、SVG)",
"web.open_full": "在 SolidCraft 中打开",
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
{
"window": [540, 360],
"title_bar": 32,
"icon_size": 148,
"text_size": 12,
"app": [132, 176],
"applications": [408, 176]
}
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Draw the SolidCraft disk image window background (background.tiff).
A blueprint sheet: deep blue with a drafting grid, a double border with ticks, a circle round each
icon, dash-dot centre lines, a leader arrow from the app to Applications and a small title block.
Every line stops short of text. The window geometry comes from layout.json, shared with
settings.py, so the art and the icons agree.
Authoring tool only (assets/AGENTS.md): run it after editing, commit the TIFF. Packaging never
runs it. Needs Python 3.11+, Pillow and macOS `tiffutil`:
python3 -m venv target/dmg-art && target/dmg-art/bin/pip install pillow
target/dmg-art/bin/python assets/platforms/macos/dmg/make_background.py [--preview DIR]
All coordinates are points; the TIFF holds a 1x and a 144 dpi 2x representation.
"""
import argparse
import json
import subprocess
import tempfile
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
HERE = Path(__file__).resolve().parent
FONTS = HERE.parents[2] / "fonts"
LAYOUT = json.loads((HERE / "layout.json").read_text())
W, H = LAYOUT["window"]
ICON = LAYOUT["icon_size"]
AX, CY = LAYOUT["app"]
BX, _ = LAYOUT["applications"]
SS = 6 # drawing scale: 3x the 2x output, reduced afterwards
PAPER_TOP, PAPER_BOTTOM = (28, 84, 158), (18, 58, 118)
LINE = (226, 238, 252)
PALE = (190, 215, 245)
# Finder draws the icon names itself, black in Light and white in Dark appearance, and the art
# cannot change that: each name sits on a mid-tone plate that both colours read on.
PLATE = (92, 132, 196)
PLATE_W, PLATE_H, PLATE_GAP = 116, 22, 8
def px(v):
return int(round(v * SS))
def sans(size, weight):
# IBMPlexSans-Variable axes: wght, wdth
f = ImageFont.truetype(str(FONTS / "ibm-plex-sans/IBMPlexSans-Variable.ttf"), px(size))
f.set_variation_by_axes([weight, 100])
return f
def mono(size):
return ImageFont.truetype(str(FONTS / "ibm-plex-mono/IBMPlexMono-Regular.ttf"), px(size))
def text(d, xy, s, f, fill, anchor="la", tracking=0.0):
"""Draw text with letter spacing (points); anchor is the usual Pillow one for the whole run."""
x, y = xy
if tracking == 0:
d.text((px(x), px(y)), s, font=f, fill=fill, anchor=anchor)
return
widths = [f.getlength(c) / SS for c in s]
total = sum(widths) + tracking * (len(s) - 1)
if anchor[0] == "m":
x -= total / 2
elif anchor[0] == "r":
x -= total
for c, w in zip(s, widths):
d.text((px(x), px(y)), c, font=f, fill=fill, anchor="l" + anchor[1])
x += w + tracking
def dashdot(d, p0, p1, fill, width=1.0, pattern=(14, 3, 2, 3)):
"""A centre line: long dash, gap, short dash, gap. Horizontal or vertical."""
(x0, y0), (x1, y1) = p0, p1
horiz = y0 == y1
a, b = (x0, x1) if horiz else (y0, y1)
pos, k = a, 0
while pos < b:
seg = min(pattern[k % 4], b - pos)
if k % 2 == 0:
q, r = ((pos, y0), (pos + seg, y0)) if horiz else ((x0, pos), (x0, pos + seg))
d.line([(px(q[0]), px(q[1])), (px(r[0]), px(r[1]))], fill=fill, width=px(width))
pos += seg
k += 1
def draw_sheet():
img = Image.new("RGB", (px(W), px(H)))
d = ImageDraw.Draw(img)
for y in range(px(H)):
t = y / px(H)
d.line([(0, y), (px(W), y)], fill=tuple(round(a + (b - a) * t) for a, b in zip(PAPER_TOP, PAPER_BOTTOM)))
# Grid on its own layer so a soft radial mask can fade it towards the edges.
grid = Image.new("RGBA", img.size, (0, 0, 0, 0))
g = ImageDraw.Draw(grid)
for i in range(0, W + 1, 12):
g.line([(px(i), 0), (px(i), px(H))], fill=(*PALE, 95 if i % 60 == 0 else 40), width=px(0.6))
for j in range(0, H + 1, 12):
g.line([(0, px(j)), (px(W), px(j))], fill=(*PALE, 95 if j % 60 == 0 else 40), width=px(0.6))
mask = Image.radial_gradient("L").resize(img.size).point(lambda v: max(0, 255 - int(v * 0.7)))
grid.putalpha(Image.composite(grid.getchannel("A"), Image.new("L", img.size, 0), mask))
img = Image.alpha_composite(img.convert("RGBA"), grid)
d = ImageDraw.Draw(img)
# Double border with a tick every 60 pt.
d.rectangle([px(10), px(10), px(W - 10), px(H - 10)], outline=(*LINE, 255), width=px(1.8))
d.rectangle([px(15), px(15), px(W - 15), px(H - 15)], outline=(*LINE, 110), width=px(0.6))
for t in range(60, W, 60):
d.line([(px(t), px(10)), (px(t), px(15))], fill=(*LINE, 255), width=px(0.8))
d.line([(px(t), px(H - 15)), (px(t), px(H - 10))], fill=(*LINE, 255), width=px(0.8))
# Title, top left. The left icon's centre line sits to its right and starts below it.
text(d, (30, 24), "SolidCraft", sans(20, 600), (*LINE, 255))
text(d, (31, 49), "PARAMETRIC 3D CAD", mono(7), (*PALE, 255), tracking=0.9)
# A circle round each icon, with centre lines that stop short of circles, plates and text.
r = ICON / 2 + 6
for cx in (AX, BX):
d.ellipse([px(cx - r), px(CY - r), px(cx + r), px(CY + r)], outline=(*LINE, 170), width=px(1.1))
cl = (*LINE, 120)
dashdot(d, (30, CY), (AX - r - 10, CY), cl)
dashdot(d, (BX + r + 10, CY), (W - 30, CY), cl)
for cx in (AX, BX):
dashdot(d, (cx, 66), (cx, CY - r - 6), cl)
dashdot(d, (cx, CY + r + 34), (cx, H - 42), cl)
# Leader from the app to Applications: origin dot, line, arrowhead, a label above.
x0, x1, y = AX + r + 8, BX - r - 8, CY + 7
d.line([(px(x0 + 4), px(y)), (px(x1 - 10), px(y))], fill=(*LINE, 255), width=px(1.8))
d.ellipse([px(x0 - 3), px(y - 3), px(x0 + 3), px(y + 3)], fill=(*LINE, 255))
d.polygon([(px(x1), px(y)), (px(x1 - 11), px(y - 4.4)), (px(x1 - 11), px(y + 4.4))], fill=(*LINE, 255))
text(d, ((x0 + x1) / 2, CY - 6), "DRAG TO INSTALL", mono(8.5), (*LINE, 255), "ms", tracking=1.4)
# Title block, bottom right: two cells.
bx1, by1 = W - 16, H - 16
d.rectangle([px(bx1 - 196), px(by1 - 18), px(bx1), px(by1)], outline=(*LINE, 255), width=px(1))
d.line([(px(bx1 - 98), px(by1 - 18)), (px(bx1 - 98), px(by1))], fill=(*LINE, 255), width=px(1))
text(d, (bx1 - 147, by1 - 8.5), "SHEET 1/1", mono(7), (*LINE, 255), "mm", tracking=1)
text(d, (bx1 - 49, by1 - 8.5), "SCALE 1:1", mono(7), (*LINE, 255), "mm", tracking=1)
# Name plates where Finder draws the icon names.
py = CY + ICON / 2 + PLATE_GAP
for cx in (AX, BX):
d.rounded_rectangle(
[px(cx - PLATE_W / 2), px(py), px(cx + PLATE_W / 2), px(py + PLATE_H)], radius=px(6), fill=(*PLATE, 255)
)
return img.convert("RGB")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--preview", help="also write the 1x and 2x PNGs to this directory")
ap.add_argument("--out", default=str(HERE / "background.tiff"))
args = ap.parse_args()
big = draw_sheet()
with tempfile.TemporaryDirectory() as tmp:
one, two = Path(tmp, "bg.png"), Path(tmp, "bg@2x.png")
big.resize((W, H), Image.LANCZOS).save(one, dpi=(72, 72))
big.resize((W * 2, H * 2), Image.LANCZOS).save(two, dpi=(144, 144))
# A multi-representation TIFF: Finder picks the 2x one on Retina screens.
raw = Path(tmp, "bg.tiff")
subprocess.run(["tiffutil", "-cat", str(one), str(two), "-out", str(raw)], check=True)
subprocess.run(["tiffutil", "-lzw", str(raw), "-out", args.out], check=True)
if args.preview:
out = Path(args.preview)
out.mkdir(parents=True, exist_ok=True)
for p in (one, two):
(out / p.name).write_bytes(p.read_bytes())
print("wrote", args.out)
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
"""dmgbuild settings for the SolidCraft disk image (packaging/macos/package.sh).
Passed with -D here=<this folder> -D app=<SolidCraft.app> -D readme=<README.txt>
-D icns=<icon.icns>. dmgbuild writes the Finder layout (window, background, icon positions) straight into the image, so the build
needs no Finder session and no AppleScript.
"""
import json
import os
here = defines["here"] # noqa: F821 (this folder; settings files have no __file__)
layout = json.load(open(os.path.join(here, "layout.json")))
width, height = layout["window"]
format = "UDZO"
filesystem = "HFS+"
# `dmgbuild -D` values arrive in `defines`.
files = [defines["app"], defines["readme"]] # noqa: F821
symlinks = {"Applications": "/Applications"}
# The read-me stays on the disk for people who look for it, out of the window's calm layout.
hide = ["README.txt"]
icon = defines["icns"] # noqa: F821
background = os.path.join(here, "background.tiff")
show_status_bar = False
show_tab_view = False
show_toolbar = False
show_pathbar = False
show_sidebar = False
default_view = "icon-view"
arrange_by = None
# The rectangle is the whole window: the title bar (32 pt on macOS 27) sits above the content area,
# which must be exactly the size of the background.
window_rect = ((260, 260), (width, height + layout["title_bar"]))
icon_size = layout["icon_size"]
text_size = layout["text_size"]
label_pos = "bottom"
icon_locations = {
os.path.basename(defines["app"]): tuple(layout["app"]), # noqa: F821
"Applications": tuple(layout["applications"]),
"README.txt": (width // 2, height + 200),
}
+1 -1
View File
@@ -1,3 +1,3 @@
# Offline authoring dependencies only; not linked into the Rust application.
numpy==2.3.5
Pillow==12.3.0
Pillow==13.0.0
+275
View File
@@ -0,0 +1,275 @@
{
"base": "dark",
"colors": {
"accent": "#e8845e",
"accent_soft": "#44281d",
"agent_busy": "#6daff7",
"agent_ok": "#89cf8e",
"app_bar": "#1b1814",
"app_bar_hover": "#e4e1dd14",
"app_bar_hover_button": "#e4e1dd18",
"app_bar_hover_strong": "#e4e1dd22",
"app_bar_hover_subtle": "#e4e1dd0c",
"app_bar_icon": [
"#c3bdb5",
"#706354",
"#e8845e"
],
"app_bar_separator": "#5d5246",
"app_bar_tab_active": "#29241f",
"app_bar_text": "#c3bdb5",
"axis_x": "#f85052",
"axis_y": "#89cf8e",
"axis_z": "#6daff7",
"bend_down": "#ab9acd",
"bend_up": "#89cf8e",
"body": "#8b8378",
"body_edge": "#1b1814",
"body_selected": "#b8603f",
"border": "#403930",
"box_crossing": "#89cf8e",
"box_window": "#6daff7",
"chip": "#e8845e",
"construction_geom": "#ceb687",
"construction_plane": "#ceb68740",
"continuity": [
"#89cf8e",
"#6daff7",
"#e6af2b",
"#f38bb7"
],
"control_border": "#5d5246",
"control_fill": "#3c352d",
"cube_border": "#706354",
"cube_face": "#3c352d",
"cube_hover": "#5d5246",
"cube_text": "#e4e1dd",
"dialog_bg": "#312b25",
"dialog_body": "#312b25",
"dialog_border": "#4b4238",
"dialog_footer": "#29241f",
"dialog_label": "#c3bdb5",
"dialog_rule": "#403930",
"dimension": "#c3bdb5",
"dimension_driven": "#9d9285",
"error": "#f85052",
"field": "#3c352d",
"form_accent": "#a6a2fc",
"form_dim": "#9d9285",
"form_error": "#ff6060",
"form_error_fill": "#37292b",
"form_fill": "#a6a2fc",
"form_ink": "#ece6d9",
"form_primary": "#e8845e",
"form_soft": "#2f3043",
"form_success": "#89cf8e",
"form_success_fill": "#2c3631",
"form_tab_ink": "#a6a2fc",
"gizmo": [
"#ff6060",
"#98dfa2",
"#7abfff"
],
"glyph_bg": "#312b25",
"glyph_edge": "#5d5246",
"glyph_text": "#ab9acd",
"grid": "#373029",
"grid_major": "#3c352d",
"heading_bg": "#221e1a",
"hover": "#4b4238",
"hover_edge": "#1b1814",
"hover_edge_halo": "#e4e1dd",
"hover_profile": "#e4e1dd22",
"hover_profile_edge": "#c3bdb5",
"icon": "#c3bdb5",
"icon_fill": "#5d5246",
"icons.access": [
"#89cf8e",
"#f85052"
],
"icons.agent": "#aba3ff",
"icons.axes": [
"#f85052",
"#89cf8e",
"#6daff7"
],
"icons.curvature": [
"#6daff7",
"#89cf8e",
"#f85052"
],
"icons.draft": [
"#89cf8e",
"#e6af2b"
],
"icons.floor": "#4b4238",
"icons.fold": "#8a7b68",
"icons.folder": "#ceb687",
"icons.glint": "#fdfcfca0",
"icons.highlight": "#e4e1dd",
"icons.interference": "#f85052",
"icons.marker_halo": "#1b181496",
"icons.paint": [
"#f85052",
"#6daff7",
"#e6af2b"
],
"icons.plane": "#dcc9a0",
"icons.sheet": "#a2bedb",
"icons.sheet_2": "#8fb0d2",
"icons.sheet_light": "#dcd8d3",
"icons.sheet_line": "#6daff7",
"icons.sky": "#a2bedb",
"icons.stripe": "#1b1814",
"icons.warning": "#e6af2b",
"interference": "#f85052",
"logo": [
"#f0a07f",
"#e8845e",
"#b1573a"
],
"manipulator": "#7abfff",
"mesh_accent": "#cfc152",
"mesh_fill": "#cfc152",
"mesh_ink": "#cfc152",
"mesh_soft": "#363624",
"on_accent": "#1d1a17",
"origin_plane": "#9d928538",
"origin_plane_edge": "#9d9285c8",
"origin_plane_hover": "#6daff770",
"origin_point": "#c3bdb5",
"overlay": "#221e1af0",
"panel": "#221e1a",
"panel_header": "#312b25",
"preview_add": "#4c78a7",
"preview_cut": "#f850526e",
"preview_new": "#e6af2b",
"preview_sketch": "#8eca96",
"profile_fill": "#6daff71c",
"render_accent": "#f38bb7",
"render_fill": "#f38bb7",
"render_ink": "#f38bb7",
"render_soft": "#3d2c36",
"rubber_band": "#7abfff",
"section_cap": "#ceb687",
"sel_edge": "#e8845e",
"sel_edge_rim": "#f5b9a0",
"sel_face": "#d4693f",
"sel_profile": "#e8845eb0",
"sel_profile_edge": "#e8845e",
"sel_vertex": "#e8845e",
"selected_bg": "#4d3329",
"selected_ink": "#ece6d9",
"sheet_fill": "#6daff7",
"sheet_ink": "#6daff7",
"sheet_soft": "#253242",
"sketch": "#6daff7",
"sketch_accent": "#8eca96",
"sketch_centerline": "#c3bdb5",
"sketch_construction": "#ceb687",
"sketch_fixed": "#e4e1dd",
"sketch_ink": "#8eca96",
"sketch_locked": "#89cf8e",
"sketch_lost": "#f85052",
"sketch_point": "#e4e1dd",
"sketch_point_free": "#6daff7",
"sketch_projected": "#998cb3",
"sketch_soft": "#2b3730",
"subheading_bg": "#29241f",
"surface_accent": "#ceb687",
"surface_fill": "#ceb687",
"surface_ink": "#ceb687",
"surface_soft": "#36342e",
"tab_on_fill_dark": "#1b1814",
"text": "#ece6d9",
"text_dim": "#9d9484",
"timeline": "#221e1a",
"timeline_item": "#312b25",
"timeline_item_error": "#552f31",
"timeline_marker": "#e8845e",
"toolbar": "#221e1a",
"triad": [
"#f85052",
"#89cf8e",
"#6daff7"
],
"viewport_bottom": "#29241f",
"viewport_top": "#312b25",
"warning": "#e6af2b"
},
"format": "solidcraft-theme/1",
"name": "Clay Dark",
"radii": {
"radius_sm": 6,
"radius_md": 9,
"radius_lg": 13,
"radius_xl": 18,
"control_radius": 9,
"sketch_mode_radius": 10
},
"borders": {
"primary": {
"width": 1,
"color": "#4a433b"
},
"secondary": {
"width": 1,
"color": "#3b362f"
},
"tertiary": {
"width": 1,
"color": "#332f2a"
},
"container": {
"width": 1,
"color": "#443d35"
},
"control": {
"width": 1,
"color": "#4a433b"
}
},
"icon_palette": {
"ink": "#262320",
"line": "#ece6d9",
"passive-1": "#c5bdae",
"passive-2": "#9a9283",
"passive-3": "#6f6859",
"guide": "#a6a093",
"solid": [
"#a0d7fb",
"#65bdf3",
"#4aa3d8"
],
"surface": [
"#fcc28e",
"#dea572",
"#c38c59"
],
"mesh": [
"#e8cd7d",
"#cab060",
"#b19747"
],
"form": [
"#d5c4fd",
"#b9a7df",
"#a08fc4"
],
"sketch": [
"#b0dbc0",
"#94bea4",
"#7ba58b"
],
"constraint": [
"#febbb9",
"#fb8e8d",
"#e57071"
],
"construction": [
"#fcc28e",
"#dea572",
"#c38c59"
]
}
}
+275
View File
@@ -0,0 +1,275 @@
{
"base": "light",
"colors": {
"accent": "#bf5634",
"accent_soft": "#f0d8ca",
"agent_busy": "#015ba6",
"agent_ok": "#257231",
"app_bar": "#e6dfd3",
"app_bar_hover": "#14110d14",
"app_bar_hover_button": "#14110d18",
"app_bar_hover_strong": "#14110d22",
"app_bar_hover_subtle": "#14110d0c",
"app_bar_icon": [
"#423e36",
"#b7b1a7",
"#bf5634"
],
"app_bar_separator": "#b7b1a7",
"app_bar_tab_active": "#faf7f1",
"app_bar_text": "#423e36",
"axis_x": "#c71529",
"axis_y": "#257231",
"axis_z": "#015ba6",
"bend_down": "#705e91",
"bend_up": "#257231",
"body": "#c8c0b3",
"body_edge": "#423e36",
"body_selected": "#e2967a",
"border": "#e0d8c9",
"box_crossing": "#257231",
"box_window": "#015ba6",
"chip": "#bf5634",
"construction_geom": "#7d6535",
"construction_plane": "#7d653540",
"continuity": [
"#257231",
"#015ba6",
"#c08a1a",
"#b14578"
],
"control_border": "#dcd3c2",
"control_fill": "#fdfaf3",
"cube_border": "#b7b1a7",
"cube_face": "#fdfaf3",
"cube_hover": "#dcd3c2",
"cube_text": "#14110d",
"dialog_bg": "#faf7f1",
"dialog_body": "#faf7f1",
"dialog_border": "#dcd3c2",
"dialog_footer": "#f4eee3",
"dialog_label": "#423e36",
"dialog_rule": "#e0d8c9",
"dimension": "#423e36",
"dimension_driven": "#726a5d",
"error": "#c71529",
"field": "#fdfaf3",
"form_accent": "#5844bc",
"form_dim": "#726a5d",
"form_error": "#c71529",
"form_error_fill": "#f4e0de",
"form_fill": "#5844bc",
"form_ink": "#2a251f",
"form_primary": "#bf5634",
"form_soft": "#e4e0e5",
"form_success": "#257231",
"form_success_fill": "#e4e9df",
"form_tab_ink": "#5844bc",
"gizmo": [
"#b00018",
"#096125",
"#004c9b"
],
"glyph_bg": "#fdfaf3",
"glyph_edge": "#dcd3c2",
"glyph_text": "#604b83",
"grid": "#f3ecdf",
"grid_major": "#e6dfd3",
"heading_bg": "#f2e9db",
"hover": "#e6dfd2",
"hover_edge": "#14110d",
"hover_edge_halo": "#fefdfb",
"hover_profile": "#14110d22",
"hover_profile_edge": "#423e36",
"icon": "#423e36",
"icon_fill": "#dcd3c2",
"icons.access": [
"#257231",
"#c71529"
],
"icons.agent": "#5844bc",
"icons.axes": [
"#c71529",
"#257231",
"#015ba6"
],
"icons.curvature": [
"#015ba6",
"#257231",
"#c71529"
],
"icons.draft": [
"#257231",
"#e6af2b"
],
"icons.floor": "#726a5d",
"icons.fold": "#938b7c",
"icons.folder": "#c9a96a",
"icons.glint": "#ffffffa0",
"icons.highlight": "#fefdfb",
"icons.interference": "#c71529",
"icons.marker_halo": "#14110d96",
"icons.paint": [
"#c71529",
"#015ba6",
"#e6af2b"
],
"icons.plane": "#e3cf9f",
"icons.sheet": "#b9cde2",
"icons.sheet_2": "#a5bfd9",
"icons.sheet_light": "#f1e9da",
"icons.sheet_line": "#015ba6",
"icons.sky": "#b9cde2",
"icons.stripe": "#14110d",
"icons.warning": "#e6af2b",
"interference": "#c71529",
"logo": [
"#d0704b",
"#bf5634",
"#92411f"
],
"manipulator": "#004c9b",
"mesh_accent": "#7e6900",
"mesh_fill": "#7e6900",
"mesh_ink": "#6b5903",
"mesh_soft": "#e7e3d8",
"on_accent": "#fffefb",
"origin_plane": "#726a5d38",
"origin_plane_edge": "#726a5dc8",
"origin_plane_hover": "#015ba670",
"origin_point": "#423e36",
"overlay": "#fefdfbeb",
"panel": "#f4eee3",
"panel_header": "#f2e9db",
"preview_add": "#4f8fd6",
"preview_cut": "#c715296e",
"preview_new": "#e6af2b",
"preview_sketch": "#2d713b",
"profile_fill": "#015ba614",
"render_accent": "#ac1f6a",
"render_fill": "#ac1f6a",
"render_ink": "#ac1f6a",
"render_soft": "#eadedf",
"rubber_band": "#004c9b",
"section_cap": "#7d6535",
"sel_edge": "#bf5634",
"sel_edge_rim": "#e8a98b",
"sel_face": "#d4714c",
"sel_profile": "#bf5634a0",
"sel_profile_edge": "#bf5634",
"sel_vertex": "#bf5634",
"selected_bg": "#f3ddd0",
"selected_ink": "#2a251f",
"sheet_fill": "#015ba6",
"sheet_ink": "#015ba6",
"sheet_soft": "#dee2e3",
"sketch": "#015ba6",
"sketch_accent": "#2d713b",
"sketch_centerline": "#423e36",
"sketch_construction": "#7d6535",
"sketch_fixed": "#14110d",
"sketch_ink": "#2d713b",
"sketch_locked": "#257231",
"sketch_lost": "#c71529",
"sketch_point": "#14110d",
"sketch_point_free": "#015ba6",
"sketch_projected": "#705e91",
"sketch_soft": "#e1e3dc",
"subheading_bg": "#f4eee3",
"surface_accent": "#7d6535",
"surface_fill": "#7d6535",
"surface_ink": "#6a562e",
"surface_soft": "#e7e3db",
"tab_on_fill_dark": "#14110d",
"text": "#2a251f",
"text_dim": "#675e50",
"timeline": "#f4eee3",
"timeline_item": "#fdfaf3",
"timeline_item_error": "#f2c5c8",
"timeline_marker": "#bf5634",
"toolbar": "#f4eee3",
"triad": [
"#c71529",
"#257231",
"#015ba6"
],
"viewport_bottom": "#ebe4d4",
"viewport_top": "#f7f2e8",
"warning": "#956000"
},
"format": "solidcraft-theme/1",
"name": "Clay",
"radii": {
"radius_sm": 6,
"radius_md": 9,
"radius_lg": 13,
"radius_xl": 18,
"control_radius": 9,
"sketch_mode_radius": 10
},
"borders": {
"primary": {
"width": 1,
"color": "#cfc4af"
},
"secondary": {
"width": 1,
"color": "#ddd3c0"
},
"tertiary": {
"width": 1,
"color": "#e3dbca"
},
"container": {
"width": 1,
"color": "#d6cbb7"
},
"control": {
"width": 1,
"color": "#d0c5b0"
}
},
"icon_palette": {
"ink": "#2a251f",
"line": "#4b4338",
"passive-1": "#e6e0d4",
"passive-2": "#b9b0a0",
"passive-3": "#8a8272",
"guide": "#7a7263",
"solid": [
"#dbe9fe",
"#81b3fa",
"#1875eb"
],
"surface": [
"#fde3c4",
"#daa662",
"#a06f28"
],
"mesh": [
"#fee7a2",
"#d1ad37",
"#937717"
],
"form": [
"#ede2ff",
"#c39afc",
"#8b62bf"
],
"sketch": [
"#b1fdc5",
"#72c78b",
"#388e56"
],
"constraint": [
"#fee0dd",
"#f98e87",
"#d73339"
],
"construction": [
"#fde3c4",
"#daa662",
"#a06f28"
]
}
}
+4 -1
View File
@@ -150,10 +150,13 @@ fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
map_plane_ref(m, base);
map_geo_ref(m, point);
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, .. } => {
PlaneRef::AtAngle { base, axis_origin, axis_dir, line, .. } => {
map_plane_ref(m, base);
*axis_origin = apply_point(m, *axis_origin);
*axis_dir = apply_vector(m, *axis_dir);
if let Some(g) = line {
map_geo_ref(m, g);
}
}
// Named origin planes and construction planes are the component's own.
PlaneRef::Origin { .. } | PlaneRef::Construction { .. } => {}
+20 -2
View File
@@ -114,11 +114,17 @@ fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<Str
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?,
PlaneRef::ThroughPoint { base, point } => {
remap_plane(base, ids, bodies, depth + 1)?;
remap_geo(point, ids, bodies, depth + 1)?;
}
PlaneRef::AtAngle { base, line, .. } => {
remap_plane(base, ids, bodies, depth + 1)?;
if let Some(g) = line {
remap_geo(g, ids, bodies, depth + 1)?;
}
}
PlaneRef::Face { name, anchor, .. } => {
if let Some(name) = name {
*name = remap_topology(name, ids, bodies);
@@ -209,6 +215,9 @@ impl crate::Feature {
checked_geo(r, 0)?;
}
}
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => checked_geo(geo, 0)?,
_ => {}
}
// Feature names are remapped once here; the geometry pass below handles only
@@ -269,6 +278,9 @@ impl crate::Feature {
remap_geo(r, ids, bodies, 0)?;
}
}
K::Revolve { axis: crate::AxisRef::Model { geo }, .. }
| K::SurfaceRevolve { axis: crate::AxisRef::Model { geo }, .. }
| K::Pattern { pattern: crate::PatternKind::Circular { axis_ref: Some(geo), .. }, .. } => remap_geo(geo, ids, bodies, 0)?,
_ => {}
}
Ok(())
@@ -289,7 +301,13 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
}
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => checked_plane(base, depth + 1)?,
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => checked_plane(base, depth + 1)?,
PlaneRef::AtAngle { base, line, .. } => {
checked_plane(base, depth + 1)?;
if let Some(g) = line {
checked_geo(g, depth + 1)?;
}
}
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
checked_geo(a, depth + 1)?;
checked_geo(b, depth + 1)?;
+83 -19
View File
@@ -162,6 +162,11 @@ fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&
/// The edge picked, as a polyline.
fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<Vec3>> {
edge_pick(st, at, name).map(|(_, _, pts)| pts)
}
/// The edge picked: its body, its index there (as `Body::edges` lists it) and its polyline.
fn edge_pick<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec<Vec3>)> {
let mut at = at;
if let Some(n) = name {
for b in bodies(st) {
@@ -171,23 +176,33 @@ fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<V
}
}
}
let mut best: Option<(f64, Vec<Vec3>)> = None;
let mut best: Option<(f64, &ModelBody, usize, Vec<Vec3>)> = None;
for b in bodies(st) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(edges) = b.body.edges(tol) else { continue };
for e in edges {
for (i, e) in edges.into_iter().enumerate() {
let d = e.points.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::MAX, f64::min);
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, e.points));
if best.as_ref().is_none_or(|(bd, ..)| d < *bd) {
best = Some((d, b, i, e.points));
}
}
}
match best {
Some((d, pts)) if pts.len() >= 2 && d <= 1e-3 + 1e-2 * pts.iter().fold(0.0, |m: f64, p| m.max(p.len())).max(1.0) => Ok(pts),
Some((d, b, i, pts)) if pts.len() >= 2 && d <= 1e-3 + 1e-2 * pts.iter().fold(0.0, |m: f64, p| m.max(p.len())).max(1.0) => Ok((b, i, pts)),
_ => Err(bad(format!("no edge at {at:?}"))),
}
}
/// The circle a picked edge lies on, from its exact curve (`kernel::edge_circle`) when the drawn
/// edge lies on it, else fitted to the drawn points: (centre, unit normal).
fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
let exact = kernel::edge_circle(&b.body, index).filter(|c| {
let tol = (b.body.size() * 1e-6).max(1e-9) * 10.0;
pts.iter().all(|p| (p.dist(c.center) - c.radius).abs() <= tol && (*p - c.center).dot(*c.axis).abs() <= tol)
});
exact.map(|c| (c.center, *c.axis)).or_else(|| as_circle(pts).map(|(c, n, _)| (c, n)))
}
/// The most edge names a vertex reference keeps.
const MAX_VERTEX_EDGES: usize = 16;
@@ -306,13 +321,52 @@ pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
}
}
/// An edge or face of the model as a line: along a straight edge, through the centre of a
/// circular edge along its normal, or the axis of a cylinder, cone or torus face.
fn model_line(st: &ModelState, g: &GeoRef) -> Result<(Vec3, Vec3)> {
match g {
GeoRef::Edge { at, name } => {
let (b, i, pts) = edge_pick(st, *at, name)?;
as_line(&pts).or_else(|| edge_circle(b, i, &pts)).ok_or_else(|| bad("the edge is neither straight nor circular"))
}
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, *axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, *axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, *axis)),
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
_ => Err(bad("pick a straight or circular edge, or a cylinder, cone or torus face")),
}
}
/// The axis a feature turns about when an edge or face of the model is picked for it (a revolve,
/// a circular pattern, a plane at an angle), as [`model_line`] finds it. The direction is
/// oriented the same way however the edge or surface runs (its largest component positive), so
/// the feature keeps its turn when an edit rebuilds the edge reversed.
pub fn model_axis(st: &ModelState, g: &GeoRef) -> Result<(Vec3, Vec3)> {
let (o, d) = model_line(st, g)?;
let d = d.normalized().filter(|_| o.is_finite()).ok_or_else(|| bad("the axis has no direction"))?;
Ok((o, canonical_dir(d)))
}
/// `d` or `-d`, whichever has its largest component (the first of equal ones) positive: how
/// [`model_axis`] orients an axis, for a preview of one to point the same way.
pub fn canonical_dir(d: Vec3) -> Vec3 {
let c = [d.x, d.y, d.z];
let big = c.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
match c.iter().find(|v| v.abs() >= big - 1e-9) {
Some(v) if *v < 0.0 => -d,
_ => d,
}
}
/// A reference as a line (origin, unit direction).
pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
match g {
GeoRef::Edge { at, name } => {
let pts = edge_points(ctx.st, *at, name)?;
as_line(&pts).or_else(|| as_circle(&pts).map(|(c, n, _)| (c, n))).ok_or_else(|| bad("the edge is neither straight nor circular"))
}
GeoRef::Edge { .. } | GeoRef::Face { .. } => model_line(ctx.st, g),
GeoRef::SketchCurve { sketch, curve } => {
let pts = sketch_curve_points(ctx, *sketch, curve)?;
as_line(&pts).ok_or_else(|| bad(format!("sketch curve `{curve}` is not a line")))
@@ -330,15 +384,6 @@ pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
ConstructGeom::Axis { origin, dir } => Ok((*origin, *dir)),
_ => Err(bad(format!("`{name}` is not an axis"))),
},
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, *axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, *axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, *axis)),
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
_ => Err(bad("pick an edge, line or axis")),
}
}
@@ -602,6 +647,20 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
}
impl GeoRef {
/// Where a picked edge or face is now, found the way evaluation finds it (by its persistent
/// name, else at the picked point): a point on it. `None` for other references and for an
/// edge or face that is gone.
pub fn picked_point(&self, st: &ModelState) -> Option<Vec3> {
match self {
GeoRef::Edge { at, name } => {
let on = name.as_deref().and_then(|n| bodies(st).find_map(|b| crate::naming::find_edge(b, n, *at).ok())).map_or(*at, |(q, _)| q);
edge_pick(st, on, &None).ok().map(|_| on)
}
GeoRef::Face { at, name } => face_of(st, *at, name).ok().map(|(_, _, p)| p),
_ => None,
}
}
/// Map the reference's world points (component frames).
pub fn map_points(&mut self, pt: &mut dyn FnMut(&mut Vec3), plane: &mut dyn FnMut(&mut PlaneRef)) {
match self {
@@ -710,7 +769,12 @@ impl PlaneRef {
refs.push(point.as_mut());
refs
}
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => base.refs_mut(),
PlaneRef::AtAngle { base, line, .. } => {
let mut v = base.refs_mut();
v.extend(line.as_deref_mut());
v
}
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => base.refs_mut(),
_ => Vec::new(),
}
}
+145 -21
View File
@@ -15,6 +15,21 @@ pub const MAX_PARAMS: usize = 10_000;
/// numbers, and far from overflowing the next id.
pub const MAX_FEATURE_ID: u64 = 1 << 52;
/// Screw direction independent of the cylinder's placement and the picked end.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(rename_all = "lowercase")]
pub enum ThreadHandedness {
#[default]
Right,
Left,
}
impl ThreadHandedness {
pub fn is_right(&self) -> bool {
*self == Self::Right
}
}
/// A named parameter. User parameters are created by the user; model parameters (`d1`, `d2`…)
/// are created by dimensions and features.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -57,8 +72,17 @@ pub enum PlaneRef {
/// Another plane moved along its normal until it goes through a point (a vertex, sketch
/// point or construction point), so it follows the point.
ThroughPoint { base: Box<PlaneRef>, point: Box<GeoRef> },
/// Another plane rotated about a line (in world coordinates) by an angle expression.
AtAngle { base: Box<PlaneRef>, axis_origin: Vec3, axis_dir: Vec3, angle: Expr },
/// Another plane rotated about a line (in world coordinates) by an angle expression. With
/// `line`, an edge or round face of the model ([`crate::construct::model_axis`]), the line is
/// found there on each evaluation and `axis_origin`/`axis_dir` are where it was when picked.
AtAngle {
base: Box<PlaneRef>,
axis_origin: Vec3,
axis_dir: Vec3,
angle: Expr,
#[serde(default, skip_serializing_if = "Option::is_none")]
line: Option<Box<GeoRef>>,
},
/// A construction plane feature (by name).
Construction { name: FeatureName },
/// `base` seen from its other side: the normal reversed by a half turn about the base's
@@ -98,13 +122,36 @@ pub enum PlaneRef {
/// Complete source curves of an outer boundary and each of its holes. Unlike a
/// span multiset, this identity is independent of adaptive spline subdivision.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
///
/// A loop is a set of curves: `outer` is kept sorted, and `holes` sorted by their own ids, as
/// the sketch's profiles produce them. Any start and either direction of the same loop is
/// accepted and made canonical on load, so a design written by hand or by another writer still
/// opens (#6499).
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WholeProfileBoundary {
pub outer: Vec<String>,
pub holes: Vec<Vec<String>>,
}
impl<'de> Deserialize<'de> for WholeProfileBoundary {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
/// The written form, before the loop order is dropped.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Written {
outer: Vec<String>,
holes: Vec<Vec<String>>,
}
let Written { mut outer, mut holes } = Written::deserialize(deserializer)?;
outer.sort();
for hole in &mut holes {
hole.sort();
}
holes.sort();
Ok(Self { outer, holes })
}
}
/// Which closed profiles of a sketch a feature uses.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -371,6 +418,9 @@ pub enum AxisRef {
World { axis: String },
/// Any line in the sketch plane, in world coordinates.
Line { origin: Position, dir: Dir },
/// An edge or round face of the model (a straight or circular edge, a cylinder, cone or
/// torus face), found again on each evaluation: [`crate::construct::model_axis`].
Model { geo: GeoRef },
/// A construction axis feature, by name, where it is when the revolve is evaluated.
Construction { name: FeatureName },
}
@@ -729,6 +779,9 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Thread depth")]
thread_depth: Option<Expr>,
/// Screw direction of the cosmetic thread; older holes are right-handed.
#[serde(default, skip_serializing_if = "ThreadHandedness::is_right")]
handedness: ThreadHandedness,
/// To Object: drill down to the face (or point) here instead of `depth`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to: Option<FacePick>,
@@ -751,6 +804,8 @@ pub enum FeatureKind {
length: Option<Expr>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
modeled: bool,
#[serde(default, skip_serializing_if = "ThreadHandedness::is_right")]
handedness: ThreadHandedness,
},
/// Ruled loft through profiles of several sketches, in order.
Loft {
@@ -1273,6 +1328,14 @@ pub enum FeatureKind {
/// The catch's height along the arm.
#[walk(length = "Catch length")]
catch_length: Expr,
/// The base extends this far below the face (none: the arm starts at the face).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Base height")]
base_height: Option<Expr>,
/// Make the snap fit its own body instead of joining the target, so it can be
/// patterned or moved on its own.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
new_body: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<BodyName>,
},
@@ -2121,6 +2184,11 @@ pub enum PatternKind {
alignment: solidcraft_sketch::PatternAlignment,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
reversed: bool,
/// The edge or round face of the model the copies turn about, found again on each
/// evaluation ([`crate::construct::model_axis`]); `origin` and `axis` are where it was
/// when picked.
#[serde(default, skip_serializing_if = "Option::is_none")]
axis_ref: Option<GeoRef>,
/// A construction axis the copies turn about, by name, followed when it moves;
/// `origin` and `axis` are where it was when the pattern was made.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -2456,6 +2524,33 @@ pub struct Feature {
pub kind: FeatureKind,
}
impl Feature {
/// Where in `body_names` the names of the first `count` bodies the feature builds are kept,
/// in the order it builds them. The same index for most features. An Import keeps its names
/// by the file's body index, and builds no body for the file bodies it skips, so its built
/// bodies pass their slots by (#2175).
pub fn body_name_slots(&self, count: usize) -> Vec<usize> {
let skip: std::collections::HashSet<u64> = match &self.kind {
FeatureKind::Import { skip, .. } => skip.iter().copied().collect(),
_ => Default::default(),
};
(0..count.saturating_add(skip.len())).filter(|k| !skip.contains(&(*k as u64))).take(count).collect()
}
/// Keep `made`, the names the feature's bodies have now (in the order it builds them), as
/// its stored names, so they stay as they are whatever comes and goes before it. An Import's
/// skipped file bodies keep the names stored for them.
pub fn pin_body_names(&mut self, made: &[String]) {
let slots = self.body_name_slots(made.len());
self.body_names.resize(slots.last().map_or(0, |s| s + 1), String::new());
for (slot, name) in slots.iter().zip(made) {
if let Some(stored) = self.body_names.get_mut(*slot) {
stored.clone_from(name);
}
}
}
}
fn inside_str() -> String {
"inside".into()
}
@@ -2505,6 +2600,11 @@ pub struct DesignLink {
pub feature: u64,
}
/// A name the design's counter hands out (`Body1`).
pub fn is_counted_body_name(name: &str) -> bool {
name.strip_prefix("Body").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) && n.parse::<usize>().is_ok())
}
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
@@ -2834,19 +2934,25 @@ impl Document {
}
/// Rename body `old` to `new` where the design keeps things by body name outside its
/// features: materials, appearances, configuration columns, offsets, components and the
/// features: materials, appearances, plastic rules, configuration columns, offsets, components and the
/// browser's groups and order. (The features are the rename command's to change.)
///
/// A name the design numbered (`Body1`) is not used again after the rename: the design's
/// counter starts over at every evaluation, so without this a design that is evaluated
/// again hands the freed name to the next body (#4957).
/// again hands the freed name to the next body (#4957). Every counted name is reserved as
/// soon as a body has it (`Session::pin_body_names`), so this only matters for a design
/// that has not been through a command since then. `new` may be a reserved name: a rename
/// is deliberate, and the body then has what the design keeps under it.
pub fn rename_body_keys(&mut self, old: &str, new: &str) {
if old.strip_prefix("Body").and_then(|n| n.parse::<usize>().ok()).is_some() {
if is_counted_body_name(old) {
self.reserved_body_names.insert(old.to_string());
}
if let Some(m) = self.materials.remove(old) {
self.materials.insert(new.to_string(), m);
}
if let Some(rule) = self.plastic.assigned.remove(old) {
self.plastic.assigned.insert(new.to_string(), rule);
}
if let Some(c) = self.appearances.bodies.remove(old) {
self.appearances.bodies.insert(new.to_string(), c);
}
@@ -2899,8 +3005,10 @@ impl Document {
continue;
}
let now = made(a.id);
// The stored slot of each body built now (an Import's pass its skipped bodies by).
let at: std::collections::HashMap<usize, &String> = b.body_name_slots(now.len()).into_iter().zip(&now).collect();
for (k, name) in b.body_names.iter().enumerate() {
let current = now.get(k);
let current = at.get(&k).copied();
// Slots the rename left alone, and the ones it set to the names they have now.
if a.body_names.get(k) == Some(name) || name.trim().is_empty() || current == Some(name) {
continue;
@@ -2949,21 +3057,30 @@ impl Document {
*self == normalized
}
/// Only components' part properties (metadata and free properties) changed: nothing to
/// Only part properties changed (the fields `with_part_properties_of` names): nothing to
/// evaluate. The same whole-Document proof as [`Document::differs_only_in_render`].
pub fn differs_only_in_part_properties(&self, previous: &Document) -> bool {
if self.components.len() != previous.components.len()
|| self.components.iter().zip(&previous.components).any(|(a, b)| a.id != b.id)
|| self.components == previous.components
{
return false;
previous.with_part_properties_of(self).is_some_and(|normalized| normalized == *self)
}
/// This design with `other`'s part properties, or `None` when they are this design's
/// already (then nothing is copied). The part properties are the fields that change no
/// geometry: the design's metadata, and each component's metadata and free properties. A
/// field that joins them is compared and copied here, and nowhere else.
fn with_part_properties_of(&self, other: &Document) -> Option<Document> {
let same = self.metadata == other.metadata
&& self.components.iter().zip(&other.components).all(|(c, o)| c.metadata == o.metadata && c.properties == o.properties);
if same {
return None;
}
let mut normalized = previous.clone();
for (n, c) in normalized.components.iter_mut().zip(&self.components) {
n.metadata = c.metadata.clone();
n.properties = c.properties.clone();
let mut out = self.clone();
out.metadata.clone_from(&other.metadata);
// Paired by position: if the components themselves differ, so do the designs.
for (c, o) in out.components.iter_mut().zip(&other.components) {
c.metadata.clone_from(&o.metadata);
c.properties.clone_from(&o.properties);
}
*self == normalized
Some(out)
}
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
@@ -3612,12 +3729,19 @@ impl Document {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => {
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle, line } => {
let b = self.resolve_plane_in(vals, base, depth + 1, st)?;
let a = Self::eval_in(vals, angle, Kind::Angle)?;
let (axis_origin, axis_dir) = match line {
Some(g) => {
let st = st.ok_or_else(|| DocError::Invalid("this plane is built from the model: evaluate first".into()))?;
crate::construct::model_axis(st, g)?
}
None => (*axis_origin, *axis_dir),
};
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos()));
let o = *axis_origin + rot(b.origin - *axis_origin);
let o = axis_origin + rot(b.origin - axis_origin);
Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
}
PlaneRef::Flipped { base, about_y } => Ok(self.resolve_plane_in(vals, base, depth + 1, st)?.flipped(*about_y)),
+363 -84
View File
@@ -348,6 +348,8 @@ pub struct ThreadInfo {
pub radius: f64,
pub pitch: f64,
pub internal: bool,
#[serde(default)]
pub handedness: crate::ThreadHandedness,
pub axis_point: Vec3,
pub axis: UnitVec3,
/// Threaded part along the axis from `axis_point`.
@@ -422,13 +424,21 @@ pub(crate) fn format_thread(d: f64, p: f64) -> String {
/// The automatic ISO metric designation for a cylindrical face, using the same fit check as
/// a thread feature without an explicit size. This query does not change the model.
pub fn suggested_thread(st: &ModelState, p: Vec3) -> Result<String> {
thread_on(st, 0, p, None, None, false).map(|t| t.designation)
thread_on(st, 0, p, None, None, false, crate::ThreadHandedness::Right).map(|t| t.designation)
}
/// A cosmetic thread on the cylindrical face at `p`. A `turned` (modelled) thread also fits a
/// shaft thicker than it, which it turns down to its major diameter first.
fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>, turned: bool) -> Result<ThreadInfo> {
thread_on_impl(st, feature, p, designation, length, false, turned)
fn thread_on(
st: &ModelState,
feature: u64,
p: Vec3,
designation: Option<&str>,
length: Option<f64>,
turned: bool,
handedness: crate::ThreadHandedness,
) -> Result<ThreadInfo> {
thread_on_impl(st, feature, p, designation, length, false, turned, handedness)
}
// Tapped holes admit verified Unified and parallel pipe sizes; the separate Thread feature remains metric,
@@ -441,6 +451,7 @@ fn thread_on_impl(
length: Option<f64>,
tapped: bool,
turned: bool,
handedness: crate::ThreadHandedness,
) -> Result<ThreadInfo> {
let (body, cyl) = st
.bodies
@@ -501,6 +512,7 @@ fn thread_on_impl(
radius: cyl.radius,
pitch,
internal: cyl.internal,
handedness,
axis_point: cyl.axis_point,
axis: cyl.axis,
start: a,
@@ -591,7 +603,59 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
if !stations.is_finite() || stations > 720.0 {
return Err(DocError::Invalid("the modelled thread is too fine for this diameter; use a cosmetic thread".into()));
}
Ok(kernel::sweep_helix_mesh(&plane, &section, base, *k, p, (s1 - s0) / p, stations as usize)?)
// The kernel rotates positively about its sweep axis. Reversing both that axis and the
// pitch mirrors the angular motion while the lift stays +k * p per turn. The physical
// axis, start/end, radial profile and station count remain exactly as for a right thread.
let (sweep_axis, sweep_pitch) = match t.handedness {
crate::ThreadHandedness::Right => (*k, p),
crate::ThreadHandedness::Left => (-*k, -p),
};
Ok(kernel::sweep_helix_mesh(&plane, &section, base, sweep_axis, sweep_pitch, (s1 - s0) / p, stations as usize)?)
}
#[cfg(test)]
mod thread_handedness_tests {
use super::*;
#[test]
fn thread_handedness_groove_reflects_exactly_about_the_physical_axis() {
for direction in [Vec3::Z, Vec3::X, Vec3::new(1.0, 2.0, 3.0)] {
for internal in [false, true] {
let axis = UnitVec3::new(direction).unwrap();
let radius = if internal { 2.5 } else { 3.0 };
let mut info = ThreadInfo {
feature: 1,
body: "Stock".into(),
designation: "M6x1".into(),
major_diameter: 6.0,
radius,
pitch: 1.0,
internal,
handedness: crate::ThreadHandedness::Right,
axis_point: Vec3::new(7.0, -5.0, 2.0),
axis,
start: 1.3,
end: 4.3,
};
let right = thread_groove(&info, radius, (false, true)).unwrap();
info.handedness = crate::ThreadHandedness::Left;
let left = thread_groove(&info, radius, (false, true)).unwrap();
// Reflection in the radial plane of the initial profile, independent of the
// helix parameterization: the projected coordinate along the axis stays fixed.
let seed = if axis.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
let normal = (seed - *axis * axis.dot(seed)).normalized().unwrap();
assert_eq!(right.positions.len(), left.positions.len());
for (r, l) in right.positions.iter().zip(&left.positions) {
let expected = *r - normal * (2.0 * (*r - info.axis_point).dot(normal));
assert!((*l - expected).len() < 1e-10, "physical reflection: {direction:?}/{internal}");
assert!(((*r - *l).dot(*axis)).abs() < 1e-10, "axial extent must not change");
}
let (r, l) = (right.measure(), left.measure());
assert!(r.volume > 0.0 && l.volume > 0.0, "outward, watertight sweep");
assert!((r.volume - l.volume).abs() < r.volume * 1e-12, "exact tool volume: {r:?}/{l:?}");
}
}
}
}
/// What a modelled external thread turns off a shaft thicker than its major diameter before it
@@ -956,6 +1020,8 @@ pub struct EvalCache {
order: std::collections::VecDeque<u64>,
/// What the stored results cost together.
bytes: usize,
/// The byte budget (0: [`CACHE_BYTES`]).
pub(crate) budget: usize,
pub hits: usize,
}
@@ -997,7 +1063,8 @@ impl EvalCache {
self.bytes = self.bytes.saturating_add(bytes);
// One result is always kept, so a single state larger than the budget cannot empty the
// cache and force a full recompute on every undo.
while self.order.len() > CACHE_ENTRIES || (self.bytes > CACHE_BYTES && self.order.len() > 1) {
let budget = if self.budget == 0 { CACHE_BYTES } else { self.budget };
while self.order.len() > CACHE_ENTRIES || (self.bytes > budget && self.order.len() > 1) {
if let Some(k) = self.order.pop_front()
&& let Some(old) = self.map.remove(&k)
{
@@ -1099,6 +1166,22 @@ impl ModelState {
bodies + sketches + self.sheets.len() * 4096 + self.threads.len() * 192 + self.construct.len() * 192
}
/// [`ModelState::retained_bytes`] of what this state holds that `before` does not: bodies
/// and sketches it shares with `before` are already paid for by the cached state that made
/// them. Charging every cached state its whole model let a design whose model is large next
/// to the budget keep only its last few states, so setting a parameter back recomputed what
/// had dropped out.
fn added_bytes(&self, before: &ModelState) -> usize {
let bodies: usize = self
.bodies
.iter()
.filter(|b| !before.bodies.iter().any(|o| o.body.same_shape(&b.body)))
.map(|b| if b.bytes > 0 { b.bytes } else { b.body.retained_bytes() })
.sum();
let sketches: usize = self.sketches.iter().filter(|s| !before.sketches.iter().any(|o| Arc::ptr_eq(o, s))).map(|s| s.retained_bytes()).sum();
bodies + sketches + self.sheets.len() * 4096 + self.threads.len() * 192 + self.construct.len() * 192
}
pub fn body(&self, name: &str) -> Option<&ModelBody> {
self.bodies.iter().find(|b| b.name == name)
}
@@ -1116,8 +1199,8 @@ impl ModelState {
}
});
}
/// [`ModelState::warm_meshes`], and every body's display plan at the current display quality
/// (what a GPU viewport draws).
/// Prepare reference meshes and display plans at the current quality for ahead jobs.
pub fn warm_display(&self) {
let q = crate::display_quality::current();
let planned = |b: &ModelBody| b.plan.get(q.index()).is_none_or(|cell| cell.get().is_some());
@@ -1127,6 +1210,19 @@ impl ModelState {
let _ = b.display_plan();
});
}
/// Prepare reference picking geometry and the GPU plan/highlight mesh at exactly the
/// requested quality. The first viewport frame then consumes initialized caches only.
pub fn warm_display_at(&self, quality: crate::display_quality::DisplayQuality, go_on: impl Fn() -> bool + Sync) {
kernel::for_each_on_threads(&self.bodies, 16, |body| {
if go_on() {
let _ = body.mesh();
}
if go_on() {
let _ = body.plan_mesh_at(quality);
}
});
}
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s)
}
@@ -1183,6 +1279,10 @@ pub struct Model {
step_digests: StepDigests,
/// The document context the last `evaluate` ran in ([`context_digest`]).
context: u64,
/// The last active result of each feature hidden by the history marker, with its document
/// context. These share the existing states, so rolling back does not surrender them to
/// the bounded cache. A result leaves this map when its feature is active again or removed.
rolled_results: std::collections::HashMap<u64, (u64, FeatureResult)>,
/// Results another copy of this model evaluated (ahead, off the window thread) that
/// `evaluate` takes for features they match ([`Model::prime`]), with that copy's context.
primed: Option<(u64, Arc<std::collections::HashMap<u64, FeatureResult>>)>,
@@ -1442,9 +1542,45 @@ fn chain_fingerprint(
}
fp = h.finish();
}
// A pattern or mirror of features replays its sources' definitions wherever they are in the
// timeline. One made with the timeline rolled back comes before them, where the chain doesn't
// cover them: its copies kept the size the sources had when they were made.
if let FeatureKind::Pattern { features, bodies, .. } | FeatureKind::Mirror { features, bodies, .. } = &f.kind
&& bodies.is_empty()
&& !features.is_empty()
{
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
for g in replayed_sources(doc, features) {
fingerprint(doc, 0, g, vals, false, steps).hash(&mut h);
}
fp = h.finish();
}
fp
}
/// The features a pattern or mirror of `features` replays: each source, and the sources of one
/// that is itself a pattern or mirror of features (see `copy_plan`), each once.
fn replayed_sources<'a>(doc: &'a Document, features: &[crate::FeatureName]) -> Vec<&'a Feature> {
let mut out: Vec<&Feature> = Vec::new();
let mut todo: Vec<String> = crate::plain(features);
// A bound on the walk, as on the copy plan's nesting: a design can't make it run on.
for _ in 0..1024 {
let Some(name) = todo.pop() else { break };
let Ok(g) = source_feature(doc, &name) else { continue };
if out.iter().any(|x| x.id == g.id) {
continue;
}
if let FeatureKind::Pattern { features, bodies, .. } | FeatureKind::Mirror { features, bodies, .. } = &g.kind
&& bodies.is_empty()
{
todo.extend(crate::plain(features));
}
out.push(g);
}
out
}
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
/// metal and plastic rules (and the parameters they use), components and body placement.
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
@@ -1966,6 +2102,8 @@ impl Model {
let mut restored = 0;
let marker = doc.marker.unwrap_or(usize::MAX);
let ctx = context_digest(doc, &vals);
let previous_context = self.context;
let mut rolled_results = std::mem::take(&mut self.rolled_results);
self.context = ctx;
self.step_digests = StepDigests::of(doc, &self.step_digests);
let primed = self.primed.as_ref().filter(|(context, _)| !self.no_cache && *context == ctx).map(|(_, results)| results.clone());
@@ -1978,6 +2116,20 @@ impl Model {
let watched = crate::progress::begin(doc.features.len());
for (i, f) in doc.features.iter().enumerate() {
let rolled_back = i >= marker;
// Retain at most one active result per rolled-back feature, even across several
// marker moves. Build the map anew so deleted features release their states too.
let rolled = rolled_results.remove(&f.id);
let rolled = if self.no_cache {
None
} else if rolled_back {
let active = self.results.get(i).filter(|r| r.id == f.id && !r.skipped && !r.stale && !r.unkept);
if let Some(saved) = active.map(|r| (previous_context, r.clone())).or(rolled) {
self.rolled_results.insert(f.id, saved);
}
None
} else {
rolled.filter(|(context, _)| *context == ctx).map(|(_, r)| r)
};
// Opening was cancelled: the rest is skipped and the result discarded.
let cancelled = watched.step(i);
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back, &self.step_digests, Some(&state));
@@ -2052,8 +2204,9 @@ impl Model {
});
continue;
}
// Evaluated ahead on these same inputs (the same chain of definitions from the start).
if let Some(r) = primed.as_ref().and_then(|p| p.get(&f.id)).filter(|r| r.fingerprint == fp && !r.stale && !r.skipped) {
// An active result hidden by the marker, or evaluated ahead on these same inputs
// (the same chain of definitions from the start). Changed inputs never reuse it.
if let Some(r) = rolled.as_ref().filter(current).or_else(|| primed.as_ref().and_then(|p| p.get(&f.id)).filter(current)) {
restored += 1;
if r.error.is_none() {
state = r.state.clone();
@@ -2063,6 +2216,7 @@ impl Model {
continue;
}
recomputed += 1;
let before = state.clone();
let t0 = now();
let mut next = (*state).clone();
// The names the design must not use again travel with the state (#4957, #4302).
@@ -2075,52 +2229,15 @@ impl Model {
// Face references by name: move each face point onto its named face (on the body the
// feature names, when it names one and the face is there).
let renamed;
// Cylindrical emboss resolves its named face in the selected body's frame
// and certifies an analytic anchor; mesh projection here would undo that.
let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. });
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
let mut g = f.clone();
let mut notes = Vec::new();
// A hole's position is a place on its face, not a pick of the face: it follows
// the face along the hole's axis and never slides sideways to the face's nearest
// point when the face no longer reaches it.
let hole_axis = match &f.kind {
FeatureKind::Hole { direction, points: None | Some(crate::SketchPoints { single: true, .. }), .. } => direction.normalized(),
_ => None,
};
let fst = face_point_state(doc, &state, &f.kind, f.component);
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
if n.is_empty() {
continue;
}
match crate::name_lookup::locate_face_on_body(&fst, f.kind.face_body(), n, *p) {
Some(hit) => {
if let Some(note) = hit.note(n) {
notes.push(note);
}
match (k, hole_axis) {
(0, Some(axis)) => match hit.along(&fst, *p, axis) {
Some(q) => *p = q,
// The place still lies on the face (the axis may run along it).
None if hit.on_point => {}
None => notes.push(format!(
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
p.x, p.y, p.z
)),
},
_ => *p = hit.point,
}
}
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
let f = match with_named_face_points(doc, &state, f) {
Some((g, notes)) => {
if !notes.is_empty() {
warning = Some(notes.join("; "));
}
renamed = g;
&renamed
}
if !notes.is_empty() {
warning = Some(notes.join("; "));
}
renamed = g;
&renamed
} else {
f
None => f,
};
// A parameter with an error fails the features using it, with that error.
let bad_param = param_names(f).into_iter().find_map(|n| self.param_errors.get(&n).map(|e| format!("parameter `{n}`: {e}")));
@@ -2189,7 +2306,7 @@ impl Model {
warning: warning.clone(),
boolean_diagnostics: boolean_diagnostics.clone(),
ms,
bytes: state.retained_bytes(),
bytes: state.added_bytes(&before),
state: state.clone(),
};
if let Some(k) = &self.kept.0 {
@@ -2325,7 +2442,7 @@ impl Model {
/// Set every result's fingerprint to `doc`'s, so the next evaluation reuses every result
/// (after a change that builds nothing differently).
fn refingerprint(&mut self, doc: &Document) {
pub fn refingerprint(&mut self, doc: &Document) {
let (vals, _) = evaluation_values(doc);
let marker = doc.marker.unwrap_or(usize::MAX);
self.step_digests = StepDigests::of(doc, &self.step_digests);
@@ -2439,6 +2556,11 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
&& !n.is_empty()
&& state.body(n).is_none()
{
// A pinned `BodyN` counts as handed out, so a design evaluated again from its stored
// names ends with the counter it had when the counter numbered them.
if let Some(number) = n.strip_prefix("Body").filter(|_| crate::is_counted_body_name(n)).and_then(|d| d.parse::<usize>().ok()) {
state.body_counter = state.body_counter.max(number);
}
return n.clone();
}
// Bodies past the stored names (a profile added after the feature was made) are numbered
@@ -4037,17 +4159,15 @@ fn blend_body(
Ok((Traced::step(nb.with_preserved_planar_sources(&mb.body)?, &[&from]), used, tapered))
}
/// The revolve axis in the sketch's 2D coordinates (a point on it, its direction). `construct`
/// gives a construction axis, by name, in the sketch's 3D frame.
pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef, construct: &dyn Fn(&str) -> Result<(Vec3, Vec3)>) -> Result<(Vec2, Vec2)> {
let in_plane = |origin: Vec3, dir: Vec3| {
let pl = &ss.plane;
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(origin).abs() > 1e-6 {
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
}
Ok((pl.to_local(origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
};
/// The revolve axis in the sketch's plane coordinates (a point and a direction). `st` is the model
/// an axis on an edge or face of it is found in; `construct` gives a construction axis, by name, in
/// the sketch's 3D frame.
pub(crate) fn revolve_axis(
ss: &SolvedSketch,
axis: &AxisRef,
st: &ModelState,
construct: &dyn Fn(&str) -> Result<(Vec3, Vec3)>,
) -> Result<(Vec2, Vec2)> {
match axis {
AxisRef::SketchLine { curve } => {
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("sketch line `{curve}`")))?;
@@ -4077,14 +4197,38 @@ pub(crate) fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef, construct: &dyn Fn
}
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
}
AxisRef::Line { origin, dir } => in_plane(origin.0, dir.0),
AxisRef::Line { origin, dir } => line_in_sketch_plane(ss, origin.0, dir.0),
AxisRef::Model { geo } => {
let (o, d) = crate::construct::model_axis(st, geo)?;
line_in_sketch_plane(ss, o, d)
}
AxisRef::Construction { name } => {
let (origin, dir) = construct(name)?;
in_plane(origin, dir)
let (o, d) = construct(name)?;
line_in_sketch_plane(ss, o, d)
}
}
}
/// The model a revolve axis is found in: what construction geometry of component `comp` is built
/// on when the axis is an edge or face of it (only then is it worth placing).
pub(super) fn axis_state<'a>(doc: &Document, st: &'a ModelState, comp: u64, axis: &AxisRef) -> std::borrow::Cow<'a, ModelState> {
match axis {
AxisRef::Model { .. } => construction_state(doc, st, comp),
_ => std::borrow::Cow::Borrowed(st),
}
}
/// A world line through `origin` along `dir` in the sketch's plane coordinates, or an error when it
/// does not lie in the sketch plane.
fn line_in_sketch_plane(ss: &SolvedSketch, origin: Vec3, dir: Vec3) -> Result<(Vec2, Vec2)> {
let pl = &ss.plane;
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(origin).abs() > 1e-6 {
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
}
Ok((pl.to_local(origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
}
/// A construction axis `name` of the timeline so far, as evaluated, in the frame of component
/// `comp`: a point on it and its direction. Without a document (`doc` None) it is taken in its
/// own component's frame.
@@ -5246,7 +5390,8 @@ fn feature_tools_with_constructor(
f.component,
)?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
let (o, d) = revolve_axis(&ss, axis, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
let (o, d) =
revolve_axis(&ss, axis, &axis_state(doc, st, f.component, axis), &|name| construction_axis_in(Some(doc), st, name, f.component))?;
// To Object is in this feature's component frame: the model as seen from there.
let tst = if to.is_some() { state_in_frame(doc, st, f.component) } else { std::borrow::Cow::Borrowed(st) };
// Where the tool meets its targets is measured where they are shown (#3383).
@@ -5658,10 +5803,18 @@ fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<crate::Expr
Ok(Region2 { outer: solidcraft_geom::Loop2::circle(Vec2::ZERO, r), holes })
}
fn map_wire(wire: &mut solidcraft_sketch::Wire, matrix: &Mat) {
pub fn map_wire(wire: &mut solidcraft_sketch::Wire, matrix: &Mat) {
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
*point = crate::apply_point(matrix, *point);
}
if let Some(arc) = &mut wire.arc {
let normal = crate::apply_vector(matrix, arc.normal);
arc.center = crate::apply_point(matrix, arc.center);
arc.start = crate::apply_point(matrix, arc.start);
arc.end = crate::apply_point(matrix, arc.end);
arc.normal = normal.normalized().unwrap_or(arc.normal);
arc.radius = arc.center.dist(arc.start);
}
}
/// A feature's sketch as its component sees it: a sketch of another component that is placed
@@ -6060,10 +6213,12 @@ fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &Model
out.push(m);
}
}
crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed, axis_feature } => {
let (origin, axis) = match axis_feature {
Some(name) => construction_axis_in(Some(doc), st, name, component)?,
None => (origin.0, axis.0),
crate::PatternKind::Circular { origin, axis, count: c, angle, alignment, reversed, axis_ref, axis_feature } => {
// On an edge or round face: about it as it is now; on a construction axis: where it is.
let (origin, axis) = match (axis_ref, axis_feature) {
(Some(g), _) => crate::construct::model_axis(&construction_state(doc, st, component), g)?,
(None, Some(name)) => construction_axis_in(Some(doc), st, name, component)?,
(None, None) => (origin.0, axis.0),
};
let n = count(c)?;
let total = val(vals, angle, Kind::Angle)?;
@@ -6189,6 +6344,54 @@ fn copied_features(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelSta
/// own sources, at each of its transforms moved by each of ours. Those copies are numbered after
/// `mats`' own (which keep the numbers they always had), each nested source in a block of its
/// own, so no two copies of one feature share an instance. Suppressed sources copy nothing.
/// `f` with each face point moved onto its named face (on the body the feature names, when it
/// names one and the face is there) in `state`, and notes for names that no longer find their
/// face. `None` when `f` names no face.
fn with_named_face_points(doc: &Document, state: &ModelState, f: &Feature) -> Option<(Feature, Vec<String>)> {
// Cylindrical emboss resolves its named face in the selected body's frame
// and certifies an analytic anchor; mesh projection here would undo that.
let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. });
if explicit_emboss || !f.face_names.iter().any(|n| !n.is_empty()) {
return None;
}
let mut g = f.clone();
let mut notes = Vec::new();
// A hole's position is a place on its face, not a pick of the face: it follows
// the face along the hole's axis and never slides sideways to the face's nearest
// point when the face no longer reaches it.
let hole_axis = match &f.kind {
FeatureKind::Hole { direction, points: None | Some(crate::SketchPoints { single: true, .. }), .. } => direction.normalized(),
_ => None,
};
let fst = face_point_state(doc, state, &f.kind, f.component);
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
if n.is_empty() {
continue;
}
match crate::name_lookup::locate_face_on_body(&fst, f.kind.face_body(), n, *p) {
Some(hit) => {
if let Some(note) = hit.note(n) {
notes.push(note);
}
match (k, hole_axis) {
(0, Some(axis)) => match hit.along(&fst, *p, axis) {
Some(q) => *p = q,
// The place still lies on the face (the axis may run along it).
None if hit.on_point => {}
None => notes.push(format!(
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
p.x, p.y, p.z
)),
},
_ => *p = hit.point,
}
}
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
}
}
Some((g, notes))
}
fn copy_plan<'a>(
doc: &'a Document,
vals: &BTreeMap<String, Value>,
@@ -6375,8 +6578,11 @@ fn replay(
}
}
} else {
// Each copy's faces are named as the source feature's, with the instance.
let tools = feature_tools_with_constructor(doc, vals, src, st, &mut None)?;
// Each copy's faces are named as the source feature's, with the instance. Its face
// points follow their named faces, as the feature's own did (#2121): a To Object
// extrude's copies reach the face where it is now, not where it was picked.
let moved = with_named_face_points(doc, st, src).map(|(g, _)| g);
let tools = feature_tools_with_constructor(doc, vals, moved.as_ref().unwrap_or(src), st, &mut None)?;
for (k, m) in mats.iter().enumerate() {
let start = copies.len();
for t in &tools {
@@ -6386,13 +6592,38 @@ fn replay(
}
}
if op == Operation::Cut {
for instance in instances {
if let Some(tools) = copies.get(instance) {
for instance in &instances {
if let Some(tools) = copies.get(instance.clone()) {
total += 1;
missed += usize::from(copy_misses(st, f, &targets, tools));
}
}
}
// Copies of a cut through sheet metal are cut-outs of its flat pattern, as the cut is
// (#6841): each machined into the sheets in turn, then cut from the other targets.
if op == Operation::Cut && matches!(src.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() && instances.len() == mats.len() {
let mut handled = Vec::with_capacity(mats.len());
for (m, instance) in mats.iter().zip(&instances) {
let bodies: Vec<Body> = copies.get(instance.clone()).unwrap_or_default().iter().map(|t| t.body.clone()).collect();
handled.push(more::sheet_cut_copy(f, src, m, st, &bodies, &targets, warning)?);
}
if handled.iter().any(|h| !h.is_empty()) {
// Copies the same sheets took are cut from the rest at once.
let mut groups: Vec<(Vec<String>, Vec<Traced>)> = Vec::new();
let mut rest = copies.into_iter();
for (done, instance) in handled.into_iter().zip(&instances) {
let tools: Vec<Traced> = rest.by_ref().take(instance.len()).collect();
match groups.iter_mut().find(|(d, _)| *d == done) {
Some((_, all)) => all.extend(tools),
None => groups.push((done, tools)),
}
}
for (done, tools) in groups {
apply_beside_sheet_cut(doc, st, f, tools, op, &targets, &done)?;
}
continue;
}
}
// New bodies from a pattern join their source when they touch it (as one feature would).
if f.body_names.is_empty() {
apply_traced(doc, st, f, copies, op, &targets)?;
@@ -6657,11 +6888,12 @@ pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u
PlaneRef::Offset { base, distance } => {
PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
}
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => PlaneRef::AtAngle {
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle, line } => PlaneRef::AtAngle {
base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
axis_origin: *axis_origin,
axis_dir: *axis_dir,
angle: angle.clone(),
line: line.clone(),
},
PlaneRef::Flipped { base, about_y } => {
PlaneRef::Flipped { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), about_y: *about_y }
@@ -6886,7 +7118,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
apply_traced(doc, st, f, tools, *operation, &crate::plain(targets))
}
FeatureKind::Hole { thread, thread_depth, diameter, .. } => {
FeatureKind::Hole { thread, thread_depth, diameter, handedness, .. } => {
let thread_depth = thread_depth.as_ref().map(|d| val(vals, d, Kind::Length)).transpose()?;
if thread_depth.is_some() && thread.is_none() {
return Err(DocError::Invalid("thread depth requires a tapped hole".into()));
@@ -6913,7 +7145,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
} else {
parallel_tapped_wall(st, p, dir, r).unwrap_or(p + dir.any_perp() * r + dir * (r * 0.5))
};
let mut t = thread_on_impl(st, f.id, wall, Some(d), None, true, false)?;
let mut t = thread_on_impl(st, f.id, wall, Some(d), None, true, false, *handedness)?;
if let Some(depth) = thread_depth {
let available = t.end - t.start;
if depth > available {
@@ -6936,12 +7168,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Thread { face, designation, length, modeled } => {
FeatureKind::Thread { face, designation, length, modeled, handedness } => {
let l = match length {
Some(e) => Some(val(vals, e, Kind::Length)?),
None => None,
};
let t = thread_on(st, f.id, face.0, designation.as_deref(), l, *modeled)?;
let t = thread_on(st, f.id, face.0, designation.as_deref(), l, *modeled, *handedness)?;
if !*modeled {
st.threads.push(t);
return Ok(());
@@ -7654,6 +7886,7 @@ fn construction_state<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std:
fn plane_is_fixed(plane: &PlaneRef) -> bool {
match plane {
PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } => true,
PlaneRef::AtAngle { line: Some(_), .. } => false,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_is_fixed(base),
_ => false,
}
@@ -7664,6 +7897,7 @@ fn plane_is_fixed(plane: &PlaneRef) -> bool {
fn plane_reads_geometry(plane: &PlaneRef) -> bool {
match plane {
PlaneRef::Origin { .. } | PlaneRef::Custom { .. } | PlaneRef::Placed { .. } | PlaneRef::Construction { .. } => false,
PlaneRef::AtAngle { line: Some(_), .. } => true,
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_reads_geometry(base),
PlaneRef::ThroughPoint { .. }
| PlaneRef::Midplane { .. }
@@ -8149,6 +8383,51 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut
out
}
#[cfg(test)]
mod display_preparation_tests {
use super::*;
fn state() -> ModelState {
let body = kernel::box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)).unwrap();
ModelState { bodies: vec![ModelBody::new("Box".into(), body, 1)], ..Default::default() }
}
#[test]
fn prepared_display_reads_all_three_caches_without_cold_geometry_or_quality_changes() {
use crate::display_quality::{DisplayQuality, current};
let state = state();
let body = &state.bodies[0];
let quality = DisplayQuality::Fine;
let index = quality.index();
let current = current();
let tolerance = body.display_tol();
assert!(body.mesh.get().is_none() && body.plan[index].get().is_none() && body.plan_mesh[index].get().is_none());
state.warm_display_at(quality, || true);
// Inspect the caches before invoking any consumer that could initialize them.
let reference = body.mesh.get().unwrap();
let plan = body.plan[index].get().unwrap().as_ref().unwrap();
let display = body.plan_mesh[index].get().unwrap().as_ref().unwrap();
assert!(Arc::ptr_eq(reference, &body.mesh()));
assert!(Arc::ptr_eq(plan, &body.display_plan_at(quality).unwrap()));
assert!(Arc::ptr_eq(display, &body.plan_mesh_at(quality).unwrap()));
assert_eq!(crate::display_quality::current(), current);
assert_eq!(body.display_tol(), tolerance);
for other in DisplayQuality::ALL.into_iter().filter(|other| *other != quality) {
assert!(body.plan[other.index()].get().is_none() && body.plan_mesh[other.index()].get().is_none());
}
}
#[test]
fn cancelled_display_preparation_leaves_cold_caches_untouched() {
let state = state();
state.warm_display_at(crate::display_quality::DisplayQuality::Normal, || false);
let body = &state.bodies[0];
assert!(body.mesh.get().is_none());
assert!(body.plan.iter().all(|slot| slot.get().is_none()));
assert!(body.plan_mesh.iter().all(|slot| slot.get().is_none()));
}
}
#[cfg(test)]
mod world_placement_cache_tests {
use super::*;
+62 -10
View File
@@ -933,13 +933,61 @@ pub(super) fn sheet_cut(
constructor: Option<&ExtrudeConstructor>,
warning: &mut Option<String>,
) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
if !any_eligible_sheet(f, st, targets) {
return Ok(Vec::new());
}
let Some(ss) = st.sketch(sketch.0).cloned() else { return Ok(Vec::new()) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
// The outlines of the sketch's text: the sheet remembers where its letters went.
let Some(cut) = extrude_cut(st, f)? else { return Ok(Vec::new()) };
sheet_prism_cut(f, st, &cut.plane, &cut.regions, cut.operation, tools, targets, constructor, cut.bound, &cut.text, warning)
}
/// A copy of the extrude cut `src` that the pattern `f` makes, moved by `m`: machined into the
/// sheets as the cut itself is, in the moved sketch plane (#6841). `tools` are the copy's. Only
/// for a cut moved without turning over: a mirrored copy, and any other feature, comes back
/// unhandled (no sheets) for the caller to apply as before.
pub(super) fn sheet_cut_copy(
f: &Feature,
src: &Feature,
m: &Mat,
st: &mut ModelState,
tools: &[Body],
targets: &[String],
warning: &mut Option<String>,
) -> Result<Vec<String>> {
if !any_eligible_sheet(f, st, targets) {
return Ok(Vec::new());
}
let Some(cut) = extrude_cut(st, src)? else { return Ok(Vec::new()) };
if cut.operation != Operation::Cut {
return Ok(Vec::new());
}
let (x, y) = (crate::apply_vector(m, cut.plane.x), crate::apply_vector(m, cut.plane.y));
if x.cross(y).dot(crate::apply_vector(m, cut.plane.normal())) <= 0.0 {
return Ok(Vec::new());
}
let Some(plane) = Plane::new(apply_point(m, cut.plane.origin), x, y) else { return Ok(Vec::new()) };
sheet_prism_cut(f, st, &plane, &cut.regions, cut.operation, tools, targets, None, cut.bound, &cut.text, warning)
}
/// What an untapered extrude cut (or intersect) machines into a sheet.
struct ExtrudeCut {
plane: Plane,
regions: Vec<Region2>,
/// The outlines of the sketch's text: the sheet remembers where its letters went.
text: Vec<Seg2>,
operation: Operation,
/// Up to a face: the tools' own footprints bound it.
bound: bool,
}
/// The sketch plane, regions and text of `f`'s extrude; None for any other feature, a tapered
/// extrude or a missing sketch.
fn extrude_cut(st: &ModelState, f: &Feature) -> Result<Option<ExtrudeCut>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(None) };
if extent.taper.is_some() {
return Ok(None);
}
let Some(ss) = st.sketch(sketch.0) else { return Ok(None) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
let text: Vec<Seg2> = ss
.sketch
.links
@@ -948,7 +996,7 @@ pub(super) fn sheet_cut(
.flat_map(|l| ss.sketch.link_curves(&l.id))
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning)
Ok(Some(ExtrudeCut { plane: ss.plane, regions, text, operation: *operation, bound: extent.to.is_some() }))
}
/// Whether a cut of `f` with these targets (else its participants, else its component's
@@ -2005,19 +2053,22 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
apply_traced(doc, st, f, tool, Operation::Join, std::slice::from_ref(&target))
}
}
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, base_height, new_body, body } => {
let target = body_under(st, body, position.0)?;
let size = |what: &str, e: &str| plastic_size("snap fit", what, len(e)?);
let (l, t, w) = (size("length", length)?, size("thickness", thickness)?, size("width", width)?);
let (cd, cl) = (size("catch_depth", catch_depth)?, size("catch_length", catch_length)?);
let bh = base_height.as_deref().map(|e| size("base_height", e)).transpose()?.unwrap_or(0.0);
if !(cl < l) {
return Err(DocError::Invalid("the snap fit's catch must be shorter than its arm".into()));
}
let (x, y, z) = local_frame(direction.0, Some(hook.0))?;
let delta = (l * 0.05).clamp(1e-3, 0.5);
// Side profile (x = toward the catch, y = up the arm), extruded across the width.
let pts =
[Vec2::new(-t, -delta), Vec2::new(0.0, -delta), Vec2::new(0.0, l - cl), Vec2::new(cd, l - cl), Vec2::new(0.0, l), Vec2::new(-t, l)];
// The base reaches `base_height` below the face; a joined arm bites `delta` into
// the wall, a separate body sits flush on it.
let y0 = if *new_body { -bh } else { -delta - bh };
let pts = [Vec2::new(-t, y0), Vec2::new(0.0, y0), Vec2::new(0.0, l - cl), Vec2::new(cd, l - cl), Vec2::new(0.0, l), Vec2::new(-t, l)];
let o = position.0 - y * (w / 2.0);
let pl = Plane::new(o, x, z).ok_or_else(|| DocError::Invalid("snap fit frame".into()))?;
let mut lp = Loop2::polygon(&pts);
@@ -2028,7 +2079,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
let (lo, hi) = if sgn > 0.0 { (0.0, w) } else { (-w, 0.0) };
let tool = kernel::extrude(&pl, &[Region2 { outer: lp, holes: Vec::new() }], lo, hi)?;
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "arm")).collect();
apply_traced(doc, st, f, tool, Operation::Join, std::slice::from_ref(&target))
let op = if *new_body { Operation::NewBody } else { Operation::Join };
apply_traced(doc, st, f, tool, op, std::slice::from_ref(&target))
}
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
let target = body_under(st, body, position.0)?;
+3 -1
View File
@@ -304,7 +304,9 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let (o, d) = revolve_axis(&ss, axis, &|name| construction_axis_in(Some(doc), st, name, f.component))?;
let (o, d) = revolve_axis(&ss, axis, &super::axis_state(doc, st, f.component, axis), &|name| {
construction_axis_in(Some(doc), st, name, f.component)
})?;
let a = val(vals, angle, Kind::Angle)?;
let (start, sweep) = match (angle2, symmetric) {
(Some(a2), _) => {
+87 -8
View File
@@ -611,6 +611,8 @@ pub fn length_unit_or_mm(units: &str) -> &str {
struct Parser<'a> {
toks: Vec<Tok>,
/// Each token's byte range in the source: whether a fraction is written closed up.
spans: Vec<(usize, usize)>,
pos: usize,
lookup: &'a dyn Fn(&str) -> Result<Value, DocError>,
depth: usize,
@@ -670,35 +672,79 @@ impl Parser<'_> {
}
}
fn unary(&mut self) -> Result<Value, DocError> {
self.signed(true)
}
/// A signed operand. `glue`: a number takes the unit written after it (not in an exponent).
fn signed(&mut self, glue: bool) -> Result<Value, DocError> {
match self.peek() {
Some(Tok::Op('-')) => {
self.pos += 1;
self.enter()?;
let v = self.unary()?;
let v = self.signed(glue)?;
self.depth -= 1;
Ok(Value { v: -v.v, ..v })
}
Some(Tok::Op('+')) => {
self.pos += 1;
self.enter()?;
let v = self.unary()?;
let v = self.signed(glue)?;
self.depth -= 1;
Ok(v)
}
_ => self.power(),
_ => self.power(glue),
}
}
fn power(&mut self) -> Result<Value, DocError> {
let base = self.atom()?;
fn power(&mut self, glue: bool) -> Result<Value, DocError> {
let number = matches!(self.peek(), Some(Tok::Num(_)));
let at = self.pos;
let mut base = self.atom()?;
// A number and the unit after it are one quantity, bound tighter than `*` and `/`:
// `10 mm / 2 mm` is 5, not (10 mm / 2) mm = 5 mm². The unit keeps its own power
// (`3 mm^2`). A name that is not a unit (`2 width`) stays an ordinary product.
if glue && number {
// A fraction of two numbers before a unit is one value, as sizes are written:
// `1/2"` and `1/2 in` are half an inch (as the mixed number `2 1/8"` is one value).
// After a division only written closed up: `1 in / 1/2 in` is 2, but
// `width / 10 / 2 mm` is (width / 10) / 2 mm.
if let (Some(Tok::Op('/')), Some(Tok::Num(d))) = (self.peek(), self.toks.get(self.pos + 1))
&& self.unit_at(self.pos + 2)
&& (!self.after_division(at) || self.closed_up(at, self.pos + 1))
{
let d = Value::num(*d);
self.pos += 2;
base = mul(base, d, true)?;
}
if self.unit_at(self.pos) {
let u = self.power(false)?;
return mul(base, u, false);
}
}
if let Some(Tok::Op('^')) = self.peek() {
self.pos += 1;
self.enter()?;
let e = self.unary()?;
// The exponent is a plain number: `2^3 mm` stays 8 mm.
let e = self.signed(false)?;
self.depth -= 1;
return pow(base, e);
}
Ok(base)
}
/// Whether the operand starting at token `i` is divided by: a `/` before it, past any signs.
fn after_division(&self, i: usize) -> bool {
let mut k = i;
while k > 0 && matches!(self.toks.get(k - 1), Some(Tok::Op('+' | '-'))) {
k -= 1;
}
k > 0 && matches!(self.toks.get(k - 1), Some(Tok::Op('/')))
}
/// Whether tokens `a` to `b` are written without spaces between them.
fn closed_up(&self, a: usize, b: usize) -> bool {
(a..b).all(|i| matches!((self.spans.get(i), self.spans.get(i + 1)), (Some(x), Some(y)) if x.1 == y.0))
}
/// Whether the token at `i` is a unit name, not called as a function.
fn unit_at(&self, i: usize) -> bool {
matches!(self.toks.get(i), Some(Tok::Id(id)) if unit(id).is_some()) && !matches!(self.toks.get(i + 1), Some(Tok::Op('(')))
}
fn atom(&mut self) -> Result<Value, DocError> {
match self.next() {
Some(Tok::Num(v)) => Ok(Value::num(v)),
@@ -931,11 +977,11 @@ pub fn eval_with(s: &str, lookup: &dyn Fn(&str) -> Result<Value, DocError>) -> R
/// Evaluate an expression reading bare numbers next to lengths / angles in `defaults`.
pub fn eval_with_defaults(s: &str, lookup: &dyn Fn(&str) -> Result<Value, DocError>, defaults: Defaults) -> Result<Value, DocError> {
let toks = lex(s)?;
let (toks, spans): (Vec<Tok>, Vec<(usize, usize)>) = lex_spans(s)?.into_iter().map(|(t, a, b)| (t, (a, b))).unzip();
if toks.is_empty() {
return Err(DocError::Expr("empty expression".into()));
}
let mut p = Parser { toks, pos: 0, lookup, depth: 0, defaults };
let mut p = Parser { toks, spans, pos: 0, lookup, depth: 0, defaults };
let v = p.expr()?;
if p.pos < p.toks.len() {
return Err(DocError::Expr(format!("unexpected input in `{s}`")));
@@ -1192,6 +1238,39 @@ mod tests {
}
}
#[test]
fn a_number_and_its_unit_are_one_quantity() {
// `10 mm / 2 mm` divides by 2 mm; it is not `(10 mm / 2) mm`, a value in mm².
assert_eq!(ev("10 mm / 2 mm", Kind::Unitless).unwrap(), 5.0);
assert_eq!(ev("10mm/2mm", Kind::Unitless).unwrap(), 5.0);
assert_eq!(ev("width / 4 mm", Kind::Unitless).unwrap(), 10.0);
assert_eq!(ev("6 mm / 2 mm * 3", Kind::Unitless).unwrap(), 9.0);
assert!((ev("90 deg / 1 rad", Kind::Unitless).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
assert!((ev("(ang - 30 deg) / 1 rad * width", Kind::Length).unwrap() - 40.0 * 60f64.to_radians()).abs() < 1e-12);
assert_eq!(ev("1 in / 1 mm", Kind::Unitless).unwrap(), 25.4);
assert_eq!(ev("2 1/2 in / 1 in", Kind::Unitless).unwrap(), 2.5);
assert_eq!(ev("1\" / 1 mm", Kind::Unitless).unwrap(), 25.4);
assert_eq!(ev("-10 mm / 2 mm", Kind::Unitless).unwrap(), -5.0);
// A fraction of numbers right before a unit stays one value: half an inch.
assert_eq!(ev("1/2 in", Kind::Length).unwrap(), 12.7);
assert_eq!(ev("width + 3/4\"", Kind::Length).unwrap(), 40.0 + 0.75 * 25.4);
assert_eq!(ev("1 in / 1/2 in", Kind::Unitless).unwrap(), 2.0);
assert_eq!(ev("10 / 2 mm", Kind::Length).unwrap(), 5.0);
// After a division, a spaced-out `/` divides again: (width / 10) / 2 mm.
assert_eq!(ev("width / 10 / 2 mm", Kind::Unitless).unwrap(), 2.0);
assert_eq!(ev("width / -10 / 2 mm", Kind::Unitless).unwrap(), -2.0);
assert_eq!(ev("width / 10/2 mm", Kind::Unitless).unwrap(), 8.0);
// The unit keeps its own power, and an exponent stays a plain number.
assert_eq!(ev("sqrt(9 mm^2)", Kind::Length).unwrap(), 3.0);
assert_eq!(ev("8 mm^3 / 2 mm^2", Kind::Length).unwrap(), 4.0);
assert_eq!(ev("2^3 mm", Kind::Length).unwrap(), 8.0);
// A number next to a name that is not a unit is a product, as before.
assert_eq!(ev("2 width", Kind::Length).unwrap(), 80.0);
// Unchanged: a unit alone, and a unit after a parenthesis.
assert_eq!(ev("width / mm", Kind::Unitless).unwrap(), 40.0);
assert_eq!(ev("(10 / 2) mm", Kind::Length).unwrap(), 5.0);
}
#[test]
fn arithmetic_and_units() {
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
@@ -0,0 +1,7 @@
//! A Revolve, Surface Revolve, Circular Pattern or Plane at Angle can turn about an edge or round
//! face of the model, kept as a reference (`AxisRef::Model`, a pattern's `axis_ref`, an angle
//! plane's `line`: a circular edge's axis through its centre, a cylinder's, cone's or torus's
//! axis, or a straight edge), found again by its persistent name on each evaluation so the
//! feature follows the edge or face when the model changes. Older designs need no
//! transformation: their axes stay the fixed lines or named axes they were saved with. The last
//! release refuses a design saved with this (the newer format number).
@@ -0,0 +1,5 @@
//! Design containers may save mesh blobs up to 512 MiB uncompressed, using Deflate when
//! a blob exceeds the 256 MiB file limit. The complete saved file still fits that limit.
//! Older designs keep their mesh coordinates, indices and compression without migration.
//! New saves carry the next format number because released readers only accept mesh
//! blobs up to 256 MiB; they must be updated to open these larger payloads.
@@ -0,0 +1,4 @@
//! Drawn 3D sketch arcs: a wire drawn as an exact circular arc now also saves its analytic
//! form (`arc`: centre, unit normal, radius, start and end) beside its three defining fit
//! points and its samples. Older designs contain no such arcs and open unchanged; a design
//! saved with this change carries the newer format number, which the last release refuses.
@@ -0,0 +1,3 @@
//! Hole and Thread features may store `handedness: "left"`. An absent value still means
//! right-handed, so older designs need no rewriting. New-format gating prevents an older
//! reader from silently dropping the direction and rebuilding a left thread as right.
+45 -15
View File
@@ -49,32 +49,35 @@ fn validate_whole_reference(value: &Value) -> Result<()> {
if total > 20_000 {
return Err(invalid());
}
let mut previous = None;
// Bound each raw ID before any owned allocation. Require canonical order.
// Bound each raw ID before any owned allocation. A loop is a set of curves: its
// written order (any start, either direction) carries no meaning, so it is made
// canonical here instead of refusing the design (#6499).
let mut ids = Vec::with_capacity(values.len());
for value in values {
let current = id(value)?;
if previous.is_some_and(|previous| previous >= current) {
return Err(invalid());
}
previous = Some(current);
ids.push(id(value)?.to_owned());
}
values.iter().map(|value| Ok(id(value)?.to_owned())).collect()
ids.sort();
if ids.windows(2).any(|pair| pair[0] == pair[1]) {
return Err(invalid());
}
Ok(ids)
};
let outer = boundary(profile.get("outer").ok_or_else(invalid)?)?;
let holes = profile.get("holes").and_then(Value::as_array).ok_or_else(invalid)?;
if holes.len() > 128 {
return Err(invalid());
}
let mut sorted_holes = Vec::with_capacity(holes.len());
let mut all: BTreeSet<_> = outer.iter().cloned().collect();
let mut canonical_holes = Vec::with_capacity(holes.len());
for hole in holes {
let hole = boundary(hole)?;
if sorted_holes.last().is_some_and(|previous| previous >= &hole) || hole.iter().any(|curve| !all.insert(curve.clone())) {
if hole.iter().any(|curve| !all.insert(curve.clone())) {
return Err(invalid());
}
sorted_holes.push(hole);
canonical_holes.push(hole);
}
if !identities.insert((outer, sorted_holes)) {
canonical_holes.sort();
if canonical_holes.windows(2).any(|pair| pair[0] == pair[1]) || !identities.insert((outer, canonical_holes)) {
return Err(invalid());
}
}
@@ -262,19 +265,19 @@ mod tests {
}
#[test]
fn whole_boundaries_raw_identity_is_bounded_canonical_and_complete() {
fn whole_boundaries_raw_identity_is_bounded_unique_and_complete() {
let good = json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"]]}]});
assert!(matches!(whole_profile_selection_from_value(&good).unwrap(), ProfileSel::WholeBoundaries { profiles } if profiles.len() == 1));
let malformed = [
json!({"type":"whole_boundaries","profiles":[]}),
json!({"type":"whole_boundaries","profiles":[{"outer":[],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s1"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2","s1"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s1"],"holes":[["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s1"],["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[["s3"],["s1"]]},{"outer":["s2"],"holes":[["s1"],["s3"]]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[],"extra":true}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s2"],"holes":[]},{"outer":["s2"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["s1","s2"],"holes":[]},{"outer":["s2","s1"],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["x".repeat(513)],"holes":[]}]}),
json!({"type":"whole_boundaries","profiles":[{"outer":["a\nb"],"holes":[]}]}),
];
@@ -300,6 +303,33 @@ mod tests {
assert!(whole_profile_selection_from_value(&json!({"type":"whole_boundaries","profiles":aggregate})).is_err());
}
#[test]
fn a_whole_boundary_loop_is_accepted_in_any_written_order() {
// Any start and either direction of one closed loop name the same region (#6499).
for curves in
[json!(["s1", "s2", "s3", "s4"]), json!(["s2", "s3", "s4", "s1"]), json!(["s4", "s3", "s2", "s1"]), json!(["s3", "s4", "s1", "s2"])]
{
let value = json!({"type":"whole_boundaries","profiles":[{"outer":curves,"holes":[]}]});
let Some(ProfileSel::WholeBoundaries { profiles }) = whole_profile_selection_from_value(&value).ok() else {
panic!("reversed loop {value} refused");
};
assert_eq!(profiles.first().map(|profile| profile.outer.clone()), Some(["s1", "s2", "s3", "s4"].map(str::to_string).to_vec()));
assert!(profiles.first().is_some_and(|profile| profile.holes.is_empty()));
assert!(validate_raw(&json!({"features":[{"type":"extrude","profiles":value}]})).is_ok());
}
// Holes are a set of loops too, and the identity is canonical, so the same loop written
// in another order is still the same profile.
let holey = json!({"type":"whole_boundaries","profiles":[{"outer":["o2","o1"],"holes":[["h2","h3"],["h1"]]}]});
let Some(ProfileSel::WholeBoundaries { profiles }) = whole_profile_selection_from_value(&holey).ok() else {
panic!("hole loop order refused");
};
let profile = profiles.first().cloned().unwrap();
assert_eq!(profile.outer, ["o1".to_string(), "o2".to_string()]);
assert_eq!(profile.holes, vec![vec!["h1".to_string()], vec!["h2".to_string(), "h3".to_string()]]);
let saved = serde_json::to_value(ProfileSel::WholeBoundaries { profiles }).unwrap_or_default();
assert_eq!(saved, json!({"type":"whole_boundaries","profiles":[{"outer":["o1","o2"],"holes":[["h1"],["h2","h3"]]}]}));
}
#[test]
fn raw_offset_roles_reject_aliases_order_damage_and_hostile_shapes() {
let good = fixture();
+14 -8
View File
@@ -1393,9 +1393,11 @@ fn closure_gaps(workspace: &mut PlacementWorkspace, closing: &[u64], vals: &Valu
}
/// How far a closing joint may be off before it is a conflict: its frames apart by more than
/// this many millimetres or turned by more than this many radians.
const CONFLICT_MM: f64 = 1e-6;
const CONFLICT_RAD: f64 = 1e-8;
/// this many millimetres or turned by more than this many radians (a micrometre over a metre).
/// Holes picked on parts are rarely exactly in line: axes a few hundredths of a microradian
/// apart (#6211) are not a conflict, and the loop through them still moves.
const CONFLICT_MM: f64 = 1e-4;
const CONFLICT_RAD: f64 = 1e-6;
/// The largest gap (`closure_gaps`, for loops of size `scale`) that still passes the conflict
/// check, with room to spare.
@@ -1405,8 +1407,9 @@ fn loose_gap(scale: f64) -> f64 {
/// Close loops whose gaps `f` gives from `x`, and the tolerance to keep them closed to from here
/// on: `tol`, or, where they come no closer than that but within `loose`, a little more than they
/// do come. Faces modelled a few nanometres apart along parallel axes leave a gap no value of a
/// planar loop takes out; the loop still moves, as closed as it is. `None` when they can't close.
/// do come. Faces modelled a few nanometres apart along parallel axes, or axes a hair out of
/// parallel, leave a gap no value of a planar loop takes out (the second one that grows as the
/// loop turns); the loop still moves, as closed as it comes. `None` when they can't close.
fn close_within(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>, bounds: &[(f64, f64)], tol: f64, loose: f64) -> Option<f64> {
if least_squares(f, x, bounds, tol) {
return Some(tol);
@@ -1714,7 +1717,8 @@ fn close_group(
// that close is left as it is.
let mut x = x0;
let start = largest(&gaps(&x, 0.0)?);
let tol = match close_within(&mut |x| gaps(x, 0.0), &mut x, &bounds, tol, loose_gap(scale))? {
let loose = loose_gap(scale);
let tol = match close_within(&mut |x| gaps(x, 0.0), &mut x, &bounds, tol, loose)? {
t if t > tol && moves.is_empty() && start <= t => return Some(BTreeMap::new()),
t => t,
};
@@ -1726,7 +1730,9 @@ fn close_group(
}
let t1 = (t + dt).min(1.0);
let mut trial = x.clone();
if least_squares(&mut |x| gaps(x, t1), &mut trial, &bounds, tol) {
// Each step as closed as the start, or within the conflict check where the loop's gap
// grows as it moves (axes a hair out of parallel, #6211).
if close_within(&mut |x| gaps(x, t1), &mut trial, &bounds, tol, loose).is_some() {
// A step that moved some loop value too far (through a link and a limit, or close to
// a dead point) is taken again in halves.
let next = effective(&trial, t1);
@@ -1741,7 +1747,7 @@ fn close_group(
dt *= 0.5;
}
}
if largest(&gaps(&x, 1.0)?) > tol {
if largest(&gaps(&x, 1.0)?) > tol.max(loose) {
return None;
}
// The values that changed from what is stored.
+4 -4
View File
@@ -10,7 +10,7 @@ use solidcraft_sketch::linalg;
use super::{
Assembly, Held, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, close_within, jacobian, joint_values,
largest, least_squares, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
largest, limit_bounds, link_chains, loop_groups, loop_scale, loose_gap, unknowns,
};
use crate::{Document, ModelState, apply_point, apply_vector, mat_mul};
@@ -325,7 +325,7 @@ fn stride(asm: &Assembly, vals: &Values, vars: &[(u64, usize)], paced: &[((u64,
/// Levenberg-Marquardt that stays on the loops: move `x` (within `bounds`, at most `cap` per value
/// and a step of every value `stride` measures, links included, a step) to bring the first `mt` values of `f` (the target's misses) toward zero, the rest (the
/// loops' weighted gaps) at zero. Each step is solved over all the rows, values on a limit the
/// step pushes past held, then put back on the loops (`least_squares` on their gaps) and taken
/// step pushes past held, then put back on the loops (`close_within` on their gaps) and taken
/// when it gets closer. It ends at the target, or as close as the limits and loops let it get.
/// Returns `f` there, or `None` when the loops don't close at the start (within `loose`).
fn approach(
@@ -341,14 +341,14 @@ fn approach(
let n = x.len();
let miss = |r: &[f64]| r.iter().take(mt).map(|v| v * v).sum::<f64>();
// The loops close first, to `tol` or as near as they come within `loose` (`close_within`),
// and stay that closed.
// and stay that closed, or within `loose` where their gap grows as they move (#6211).
let loops = f(x)?.len() > mt;
let mut loop_tol = tol * LOOP_WEIGHT;
if loops {
loop_tol = close_within(&mut |y| f(y).map(|r| r.get(mt..).unwrap_or_default().to_vec()), x, bounds, loop_tol, loose * LOOP_WEIGHT)?;
}
let close = |f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>| {
least_squares(&mut |y| f(y).map(|r| r.get(mt..).unwrap_or_default().to_vec()), x, bounds, loop_tol)
close_within(&mut |y| f(y).map(|r| r.get(mt..).unwrap_or_default().to_vec()), x, bounds, loop_tol, loose * LOOP_WEIGHT).is_some()
};
let mut r = f(x)?;
let (mut lambda, mut kicked) = (1e-3, false);
+4 -3
View File
@@ -76,9 +76,10 @@ pub use document::*;
pub use eval::KeptResults;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, curve_pick_error,
edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, metric_thread_sizes, object_height,
parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane,
state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state, world_state_reusing,
edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, map_wire, metric_thread_sizes,
object_height, parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction,
sketch_line_plane, state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state,
world_state_reusing,
};
pub use expr::Expr;
pub use names::{BodyName, FeatureName, SketchId};
+345 -58
View File
@@ -1,14 +1,17 @@
//! Design properties (#1015): the design's optional top-level `metadata` object.
//! Design and part properties (#1015): the optional `metadata` object of the design and of each
//! component.
//!
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
//! revision, author, organization, description, licence) and keeps every other key exactly as
//! it was through open, edit and save. Other tools keep their own data under one reverse-DNS
//! key each (`com.example.tool`), holding an object.
//! revision, author, organization, description, licence), listed once in [`KEYS`] with where
//! each may be set (the design, a component or both), how its value is checked and its label.
//! It keeps every other key exactly as it was through open, edit and save. Other tools keep
//! their own data under one reverse-DNS key each (`com.example.tool`), holding an object.
//!
//! Reading never validates: a well-known key holding something other than text is kept as it
//! is and reads as absent, so a file another tool wrote always opens. Writing through
//! [`Metadata::set`] (the `document.metadata.set` command) checks the key, the value's kind and
//! its size, so nothing SolidCraft writes is malformed.
//! [`Metadata::set`] (the `document.metadata.set` and `component.metadata.set` commands) checks
//! the key against where it is set, the value's kind and its size, so nothing SolidCraft writes
//! is malformed.
//!
//! The map is stored as is: typed fields would refuse a design another tool had written
//! `"author": 42` into. Everything under `metadata` is informational, never geometry, so keeping
@@ -27,9 +30,67 @@ pub const ORGANIZATION: &str = "organization";
pub const DESCRIPTION: &str = "description";
pub const LICENSE: &str = "license";
/// SolidCraft's own keys, in the order the Design Properties dialog shows them. Every other
/// unprefixed key is reserved for SolidCraft too.
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
/// Where a key may be set: on the design, on a component, or on both.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scope {
Design,
Component,
Both,
}
impl Scope {
/// Whether a key of this scope may be set in `owner`'s metadata.
pub fn covers(self, owner: Owner) -> bool {
matches!((self, owner), (Scope::Both, _) | (Scope::Design, Owner::Design) | (Scope::Component, Owner::Component))
}
}
/// Whose metadata a change is to: the design's (`document.metadata.set`) or a component's
/// (`component.metadata.set`). [`Metadata::set`] is told which.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Owner {
Design,
Component,
}
impl Owner {
/// What one of its properties, and several, are called in a refusal.
fn names(self) -> (&'static str, &'static str) {
match self {
Owner::Design => ("design property", "design properties"),
Owner::Component => ("part property", "part properties"),
}
}
}
/// One of SolidCraft's own keys.
pub struct Key {
pub name: &'static str,
/// Where it may be set.
pub scope: Scope,
/// The message id of its label (the Design Properties dialog, the Bill of Materials window).
pub label: &'static str,
/// The value as it is saved (`None`: the key is removed), or why it can't be; given the key
/// to name it in the refusal.
check: fn(&str, Value) -> Result<Option<Value>>,
}
/// SolidCraft's own keys, in the order the Design Properties dialog shows them; a new key is one
/// row here. Every other unprefixed key is reserved for SolidCraft too.
pub static KEYS: &[Key] = &[
Key { name: PROJECT, scope: Scope::Both, label: "ui.design_properties.project", check: text },
Key { name: PART_NUMBER, scope: Scope::Both, label: "ui.design_properties.part_number", check: text },
Key { name: REVISION, scope: Scope::Both, label: "ui.design_properties.revision", check: text },
Key { name: AUTHOR, scope: Scope::Both, label: "ui.design_properties.author", check: text },
Key { name: ORGANIZATION, scope: Scope::Both, label: "ui.design_properties.organization", check: text },
Key { name: DESCRIPTION, scope: Scope::Both, label: "ui.design_properties.description", check: text },
Key { name: LICENSE, scope: Scope::Both, label: "ui.design_properties.license", check: text },
];
/// The keys a bill of materials lists for each part, in the order of its columns:
/// `component.metadata` returns each, `component.metadata.set` takes each as a parameter of its
/// own, and the Bill of Materials window edits them.
pub static BOM_KEYS: &[&str] = &[PART_NUMBER, DESCRIPTION, REVISION];
/// Longest key, in bytes.
pub const MAX_KEY_BYTES: usize = 128;
@@ -43,14 +104,29 @@ pub const MAX_VALUE_DEPTH: usize = 32;
pub const MAX_KEYS: usize = 256;
pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
/// The design's `metadata` object.
/// A design's or a component's `metadata` object.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Metadata(pub Map<String, Value>);
/// A key SolidCraft reads as a design property.
pub fn is_well_known(key: &str) -> bool {
WELL_KNOWN.contains(&key)
/// SolidCraft's keys that may be set in `owner`'s metadata, in [`KEYS`]' order.
pub fn keys(owner: Owner) -> impl Iterator<Item = &'static Key> {
KEYS.iter().filter(move |k| k.scope.covers(owner))
}
/// The names of [`keys`]`(owner)`, as refusals list them.
pub fn key_list(owner: Owner) -> String {
keys(owner).map(|k| k.name).collect::<Vec<_>>().join(", ")
}
/// One of SolidCraft's keys, by name.
pub fn key(name: &str) -> Option<&'static Key> {
KEYS.iter().find(|k| k.name == name)
}
/// A key SolidCraft reads as a design or part property.
pub fn is_well_known(name: &str) -> bool {
key(name).is_some()
}
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
@@ -61,6 +137,31 @@ pub fn is_namespaced(key: &str) -> bool {
&& key.split('.').all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
}
/// Text SolidCraft keeps as a property: at most [`MAX_TEXT_BYTES`], and no control characters
/// but line breaks and tabs. `what` names it in the refusal.
pub fn check_text(what: &str, text: &str) -> Result<()> {
if text.len() > MAX_TEXT_BYTES {
return Err(invalid(format!("{what} is longer than {MAX_TEXT_BYTES} bytes")));
}
if text.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
return Err(invalid(format!("{what} has a control character")));
}
Ok(())
}
/// A text key's check: text, which empty text (like `null`) removes.
fn text(key: &str, value: Value) -> Result<Option<Value>> {
match value {
Value::Null => Ok(None),
Value::String(s) if s.is_empty() => Ok(None),
Value::String(s) => {
check_text(&format!("`{key}`"), &s)?;
Ok(Some(Value::String(s)))
}
_ => Err(invalid(format!("`{key}` takes text"))),
}
}
fn depth(v: &Value) -> usize {
// Iterative, so a hostile value can't overflow the stack here.
let mut deepest = 0;
@@ -129,10 +230,10 @@ impl Metadata {
self.0.iter().filter(|(k, _)| !is_well_known(k))
}
/// Set `key` to `value` after checking both; `null` (or empty text for a well-known key)
/// removes it. Returns whether the metadata changed.
pub fn set(&mut self, key: &str, value: Value) -> Result<bool> {
let value = check(key, value)?;
/// Set `key` to `value` in `owner`'s metadata, after checking both; `null` (or empty text for
/// a text key) removes it. Returns whether the metadata changed.
pub fn set(&mut self, owner: Owner, key: &str, value: Value) -> Result<bool> {
let value = check(owner, key, value)?;
let Some(value) = value else { return Ok(self.remove(key)) };
if self.0.get(key) == Some(&value) {
return Ok(false);
@@ -146,7 +247,7 @@ impl Metadata {
Some(old) => self.0.insert(key.to_string(), old),
None => self.0.remove(key),
};
return Err(invalid(format!("design properties are limited to {MAX_KEYS} keys and {} KiB", MAX_TOTAL_BYTES / 1024)));
return Err(invalid(format!("{} are limited to {MAX_KEYS} keys and {} KiB", owner.names().1, MAX_TOTAL_BYTES / 1024)));
}
Ok(true)
}
@@ -157,23 +258,10 @@ impl Metadata {
}
}
/// `value` as `key` may hold it (`None`: remove the key), or why it can't.
fn check(key: &str, value: Value) -> Result<Option<Value>> {
if is_well_known(key) {
return match value {
Value::Null => Ok(None),
Value::String(s) if s.is_empty() => Ok(None),
Value::String(s) => {
if s.len() > MAX_TEXT_BYTES {
return Err(invalid(format!("`{key}` is longer than {MAX_TEXT_BYTES} bytes")));
}
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
return Err(invalid(format!("`{key}` has a control character")));
}
Ok(Some(Value::String(s)))
}
_ => Err(invalid(format!("`{key}` takes text"))),
};
/// `value` as `key` may hold it in `owner`'s metadata (`None`: remove the key), or why it can't.
fn check(owner: Owner, key: &str, value: Value) -> Result<Option<Value>> {
if let Some(k) = self::key(key).filter(|k| k.scope.covers(owner)) {
return (k.check)(key, value);
}
if is_namespaced(key) {
return match value {
@@ -193,13 +281,15 @@ fn check(key: &str, value: Value) -> Result<Option<Value>> {
}
let shown: String = key.chars().take(64).collect();
Err(invalid(format!(
"`{shown}` is not a design property: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
WELL_KNOWN.join(", ")
"`{shown}` is not a {}: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
owner.names().0,
key_list(owner)
)))
}
#[cfg(test)]
mod tests {
use super::Owner::{Component, Design};
use super::*;
use serde_json::json;
@@ -216,30 +306,30 @@ mod tests {
#[test]
fn set_checks_keys_kinds_and_sizes() {
let mut m = Metadata::default();
assert!(m.set("author", json!("Jane")).unwrap());
assert!(!m.set("author", json!("Jane")).unwrap(), "the same value is no change");
assert!(m.set("com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
assert!(m.set("author", json!(42)).is_err(), "well-known keys take text");
assert!(m.set("author", json!("a\u{7}b")).is_err(), "no control characters");
assert!(m.set("description", json!("two\nlines\tand a tab")).unwrap());
assert!(m.set("com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
assert!(m.set("colour", json!("red")).is_err(), "unprefixed keys are reserved");
assert!(m.set("", json!({})).is_err());
assert!(m.set(Design, "author", json!("Jane")).unwrap());
assert!(!m.set(Design, "author", json!("Jane")).unwrap(), "the same value is no change");
assert!(m.set(Design, "com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
assert!(m.set(Design, "author", json!(42)).is_err(), "well-known keys take text");
assert!(m.set(Design, "author", json!("a\u{7}b")).is_err(), "no control characters");
assert!(m.set(Design, "description", json!("two\nlines\tand a tab")).unwrap());
assert!(m.set(Design, "com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
assert!(m.set(Design, "colour", json!("red")).is_err(), "unprefixed keys are reserved");
assert!(m.set(Design, "", json!({})).is_err());
for bad in ["com.", ".com", "com..x", "com.ex ample", "com.é", "single"] {
assert!(m.set(bad, json!({})).is_err(), "{bad}");
assert!(m.set(Design, bad, json!({})).is_err(), "{bad}");
}
assert!(m.set("author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
assert!(m.set("org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
assert!(m.set(Design, "author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
assert!(m.set(Design, "org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
let mut deep = json!({});
for _ in 0..MAX_VALUE_DEPTH {
deep = json!({ "a": deep });
}
assert!(m.set("org.deep", deep).is_err());
assert!(m.set(Design, "org.deep", deep).is_err());
assert_eq!(m.author(), Some("Jane"), "a refused value leaves the old one");
// Null and empty text remove.
assert!(m.set("author", json!("")).unwrap());
assert!(m.set("com.acme.tool", Value::Null).unwrap());
assert!(!m.set("com.acme.tool", Value::Null).unwrap());
assert!(m.set(Design, "author", json!("")).unwrap());
assert!(m.set(Design, "com.acme.tool", Value::Null).unwrap());
assert!(!m.set(Design, "com.acme.tool", Value::Null).unwrap());
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["description"]);
}
@@ -247,17 +337,214 @@ mod tests {
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
let mut m = Metadata::default();
for i in 0..MAX_KEYS {
m.set(&format!("org.k{i}"), json!({})).unwrap();
m.set(Design, &format!("org.k{i}"), json!({})).unwrap();
}
assert!(m.set("org.one_more", json!({})).is_err());
assert!(m.set(Design, "org.one_more", json!({})).is_err());
assert_eq!(m.0.len(), MAX_KEYS);
// Each owner's refusal names its own properties.
let refusal = |owner| m.clone().set(owner, "org.one_more", json!({})).unwrap_err().to_string();
assert_eq!(refusal(Design), "design properties are limited to 256 keys and 256 KiB");
assert_eq!(refusal(Component), "part properties are limited to 256 keys and 256 KiB");
// A file written past the limits by another tool still opens and can be cut down.
let mut big = Metadata::default();
for i in 0..MAX_KEYS + 10 {
big.0.insert(format!("org.k{i}"), json!({}));
}
assert!(big.set("org.k0", Value::Null).unwrap());
assert!(big.set("author", json!("Jane")).is_err(), "still over: no new keys");
assert!(big.set("org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
assert!(big.set(Design, "org.k0", Value::Null).unwrap());
assert!(big.set(Design, "author", json!("Jane")).is_err(), "still over: no new keys");
assert!(big.set(Design, "org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
}
#[test]
fn each_key_is_one_row_in_the_order_the_dialog_shows() {
let names: Vec<&str> = KEYS.iter().map(|k| k.name).collect();
assert_eq!(names, [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE]);
for (i, k) in KEYS.iter().enumerate() {
assert!(!names[..i].contains(&k.name), "`{}` has two rows", k.name);
assert!(!k.name.is_empty() && !is_namespaced(k.name), "`{}` is unprefixed", k.name);
assert!(k.label.starts_with("ui.") && !KEYS[..i].iter().any(|o| o.label == k.label), "`{}`'s label", k.name);
assert!(is_well_known(k.name) && key(k.name).is_some_and(|f| std::ptr::eq(f, k)));
}
for name in ["", "colour", "Author", "author ", "com.acme.tool"] {
assert!(!is_well_known(name), "{name:?}");
}
// Every key SolidCraft took on the design and on a component before the table still is.
assert_eq!(key_list(Design), "project, part_number, revision, author, organization, description, license");
assert_eq!(key_list(Component), key_list(Design));
}
#[test]
fn the_bill_of_materials_keys_are_rows_a_component_takes_in_column_order() {
assert_eq!(BOM_KEYS, [PART_NUMBER, DESCRIPTION, REVISION], "the bill of materials' columns");
for name in BOM_KEYS {
assert!(key(name).is_some_and(|k| k.scope.covers(Component)), "`{name}` is a row of KEYS a component takes");
}
}
#[test]
fn a_key_is_taken_only_where_its_scope_covers_and_a_refusal_names_the_owner() {
let covered: Vec<bool> =
[Scope::Design, Scope::Component, Scope::Both].into_iter().flat_map(|s| [s.covers(Design), s.covers(Component)]).collect();
assert_eq!(covered, [true, false, false, true, true, true], "design, component and both, on the design and on a component");
for owner in [Design, Component] {
for k in KEYS {
assert_eq!(Metadata::default().set(owner, k.name, json!("x")).is_ok(), k.scope.covers(owner), "`{}` on {owner:?}", k.name);
}
assert!(Metadata::default().set(owner, "com.acme.tool", json!({"id": 1})).unwrap(), "another tool's key, on {owner:?}");
}
let refusal = |owner, key: &str| Metadata::default().set(owner, key, json!("x")).unwrap_err().to_string();
assert_eq!(
refusal(Design, "colour"),
"`colour` is not a design property: SolidCraft's are project, part_number, revision, author, organization, \
description, license; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
"the design's wording is as it was"
);
assert_eq!(
refusal(Component, "colour"),
"`colour` is not a part property: SolidCraft's are project, part_number, revision, author, organization, \
description, license; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
);
for owner in [Design, Component] {
let word = owner.names().0;
assert!(refusal(owner, "").starts_with(&format!("`` is not a {word}:")), "the empty key on {owner:?}");
assert!(refusal(owner, "Part_Number").starts_with("`Part_Number` is not"), "keys are matched exactly");
let long = "k".repeat(MAX_KEY_BYTES * 2);
assert!(refusal(owner, &long).starts_with(&format!("`{}` is not a {word}", "k".repeat(64))), "shown cut to 64 characters");
}
}
#[test]
fn a_text_key_takes_up_to_its_limit_with_line_breaks_and_tabs_only() {
for owner in [Design, Component] {
let set = |value: Value| Metadata::default().set(owner, AUTHOR, value);
assert!(set(json!("x".repeat(MAX_TEXT_BYTES))).unwrap(), "exactly the limit");
assert!(set(json!("é".repeat(MAX_TEXT_BYTES / 2))).unwrap(), "bytes, not characters: 4096");
let over = set(json!(format!("{}é", "x".repeat(MAX_TEXT_BYTES - 1)))).unwrap_err().to_string();
assert_eq!(over, "`author` is longer than 4096 bytes", "one character, two bytes past the limit");
assert!(set(json!("a\nb\r\nc\td")).unwrap(), "line breaks and tabs");
assert!(set(json!(" ")).unwrap(), "a space is text, kept as it is");
for c in ['\u{0}', '\u{7}', '\u{1b}', '\u{7f}', '\u{85}'] {
let refused = set(json!(format!("a{c}b"))).unwrap_err().to_string();
assert_eq!(refused, "`author` has a control character", "{:?}", c);
}
for kind in [json!(42), json!(true), json!(["x"]), json!({"text": "x"})] {
assert_eq!(set(kind.clone()).unwrap_err().to_string(), "`author` takes text", "{kind}");
}
// Null and empty text remove the key, and removing a missing one is no change.
let mut m = Metadata::default();
assert!(m.set(owner, AUTHOR, json!("Jane")).unwrap());
assert!(m.set(owner, AUTHOR, json!("")).unwrap() && m.is_empty());
assert!(!m.set(owner, AUTHOR, Value::Null).unwrap());
}
assert_eq!(
check_text("property `finish`", &"x".repeat(MAX_TEXT_BYTES + 1)).unwrap_err().to_string(),
"property `finish` is longer than 4096 bytes"
);
}
/// Every hostile key with every hostile value, on the design and on a component: no panic,
/// and a refusal leaves the metadata exactly as it was.
#[test]
fn hostile_keys_and_values_are_refused_without_a_change() {
let long_key = "k".repeat(MAX_KEY_BYTES + 1);
let long_tool = format!("com.{}", "k".repeat(MAX_KEY_BYTES));
let keys = [
"",
" ",
".",
"a.",
".a",
"a..b",
"author",
"AUTHOR",
" author",
"part_number\n",
"é",
"\u{0}",
"com.acme.tool",
long_key.as_str(),
long_tool.as_str(),
];
let mut deep = json!({});
for _ in 0..MAX_VALUE_DEPTH {
deep = json!({ "a": deep });
}
let values = [
Value::Null,
json!(""),
json!(" "),
json!(0),
json!(-1.5e308),
json!(true),
json!([]),
json!({}),
json!("a\u{0}b"),
json!("x".repeat(MAX_TEXT_BYTES)),
json!("x".repeat(MAX_TEXT_BYTES + 1)),
json!({"a": "x".repeat(MAX_VALUE_BYTES)}),
deep,
];
for owner in [Design, Component] {
for key in keys {
for value in &values {
let mut m: Metadata = serde_json::from_value(json!({"author": "Jane", "com.acme.tool": {"id": 7}})).unwrap();
let before = m.clone();
if m.set(owner, key, value.clone()).is_err() {
assert_eq!(m, before, "{owner:?} {key:?} = {value}");
}
}
}
}
}
/// "Fields that change no geometry" covers each scope: the design's properties, a
/// component's, and its free properties. Each alone, and its undo, is a part-property
/// change (nothing to evaluate); anything else beside it is not.
#[test]
fn only_part_properties_changed_in_each_scope() {
let mut before = crate::Document::default();
for (id, name) in [(1, "Plate"), (2, "Pin")] {
before.components.push(crate::Component { id, name: name.into(), ..Default::default() });
}
let mut design = before.clone();
design.metadata.set(Design, AUTHOR, json!("Jane")).unwrap();
let mut part = before.clone();
part.components[1].metadata.set(Component, PART_NUMBER, json!("PN-040")).unwrap();
let mut free = before.clone();
free.components[0].properties.insert("finish".into(), "zinc".into());
let mut all = design.clone();
all.components.clone_from(&part.components);
all.components[0].properties.clone_from(&free.components[0].properties);
for (what, after) in [("design", &design), ("component", &part), ("free property", &free), ("all three", &all)] {
assert!(after.differs_only_in_part_properties(&before), "{what}");
assert!(before.differs_only_in_part_properties(after), "{what}, undone");
}
assert!(!before.differs_only_in_part_properties(&before.clone()), "no change is not a part-property change");
let mut units = design.clone();
units.units = "in".into();
let mut renamed = part.clone();
renamed.components[1].name = "Dowel".into();
let mut added = free.clone();
added.components.push(crate::Component { id: 3, name: "Nut".into(), ..Default::default() });
let mut removed = part.clone();
removed.components.remove(0);
// A part property changed on components that swapped places: the components changed too.
let mut swapped = before.clone();
swapped.components.reverse();
swapped.components[0].metadata.set(Component, PART_NUMBER, json!("PN-040")).unwrap();
let mut next_id = design.clone();
next_id.next_id += 1;
for (what, after) in [
("units", &units),
("a component's name", &renamed),
("a new component", &added),
("a removed component", &removed),
("swapped components", &swapped),
("next id", &next_id),
] {
assert!(!after.differs_only_in_part_properties(&before), "{what}");
assert!(!before.differs_only_in_part_properties(after), "{what}, undone");
}
}
}
+1 -1
View File
@@ -618,7 +618,7 @@ impl Model {
}
results.push(result);
}
let mut model = Model { results, param_errors, step_digests, frame_bodies, ..Model::default() };
let mut model = Model { results, param_errors, step_digests, context: ctx, frame_bodies, ..Model::default() };
let unkept = model.results.iter().filter(|r| r.unkept).count();
if unkept > 0 {
// The features whose state the file did not keep, each evaluated again from the state
+1 -1
View File
@@ -696,7 +696,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
}
FeatureKind::Revolve { sketch, axis, .. } => {
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis, &|name| crate::eval::construction_axis_in(None, before, name, 0)) else {
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis, before, &|name| crate::eval::construction_axis_in(None, before, name, 0)) else {
return format!("F{fid}:face:{fi}");
};
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
+56
View File
@@ -234,6 +234,22 @@ impl Document {
}
}
/// A measured length (mm) written as a value in the design's units, with its unit: `1 in`
/// for 25.4 mm in an inch design. Kept to the micrometre, as a millimetre value would be, so
/// a dimension that restates the geometry moves nothing. Returns (unit, expression).
pub fn design_length(&self, mm: f64) -> (String, String) {
let u = self.units.trim();
let per_unit = match expr::unit_info(u) {
Some((Kind::Length, s)) if u != "mm" && s.is_finite() && s > 0.0 => s,
_ => return ("mm".into(), format!("{} mm", (mm * 1e6).round() / 1e6)),
};
let decimals = (-(1e-6 / per_unit).log10()).ceil().clamp(0.0, 12.0) as usize;
let text = format!("{:.*}", decimals, mm / per_unit);
let text = if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.') } else { &text };
let text = if text == "-0" { "0" } else { text };
(u.into(), format!("{text} {u}"))
}
/// Every length input of every feature as the geometry reads it (mm; a unit-less result is
/// in the design's units), for checking that an edit moves nothing.
pub fn length_inputs(&self) -> Vec<Option<f64>> {
@@ -1088,3 +1104,43 @@ pub(crate) fn numbers_after<'a>(base: &str, names: impl Iterator<Item = &'a str>
.filter_map(|digits| digits.parse().ok())
.collect()
}
#[cfg(test)]
mod design_length_tests {
use crate::Document;
fn written(units: &str, mm: f64) -> (String, String) {
let mut doc = Document::new("Units");
doc.units = units.into();
doc.design_length(mm)
}
#[test]
fn a_measured_length_is_written_in_the_design_units() {
let w = |u: &str, e: &str| (u.to_string(), e.to_string());
assert_eq!(written("in", 25.4), w("in", "1 in"));
assert_eq!(written("in", 12.7), w("in", "0.5 in"));
assert_eq!(written("in", 10.0), w("in", "0.39370079 in"));
assert_eq!(written("cm", 12.34), w("cm", "1.234 cm"));
assert_eq!(written("mm", 12.3456789), w("mm", "12.345679 mm"));
assert_eq!(written("in", -25.4), w("in", "-1 in"));
assert_eq!(written("in", -1e-12), w("in", "0 in"));
// Unknown or non-length units keep millimetres.
assert_eq!(written("parsec", 5.0), w("mm", "5 mm"));
assert_eq!(written("deg", 5.0), w("mm", "5 mm"));
assert_eq!(written("", 5.0), w("mm", "5 mm"));
}
#[test]
fn the_written_value_reads_back_within_a_micrometre() {
for units in ["mm", "cm", "m", "in", "ft"] {
let mut doc = Document::new("Units");
doc.units = units.into();
for mm in [0.001, 1.0, 12.345_678_9, 25.4, 999.999, 12_345.678] {
let (_, e) = doc.design_length(mm);
let back = doc.eval(&e, crate::expr::Kind::Length).unwrap();
assert!((back - mm).abs() <= 1e-6, "{units} {mm}: {e} reads {back}");
}
}
}
}
+20
View File
@@ -363,6 +363,7 @@ pub fn capture_profile_selection(ss: &SolvedSketch, indices: &[usize]) -> Result
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
use serde_json::json;
use solidcraft_geom::{Plane, Vec2};
use solidcraft_sketch::{Sketch, find_profiles, solve};
@@ -518,6 +519,25 @@ mod tests {
assert!(profile_selection_indices(&ss, &ProfileSel::Indices { indices: vec![1] }).is_err());
}
#[test]
fn a_whole_source_selection_loads_in_any_curve_order() {
let ss = solved_square(&[]);
assert_eq!(ss.profiles.len(), 1);
let canonical = ProfileSel::WholeBoundaries {
profiles: vec![WholeProfileBoundary { outer: ["a", "b", "c", "d"].map(str::to_string).to_vec(), holes: Vec::new() }],
};
assert_eq!(profile_selection_indices(&ss, &canonical).unwrap(), vec![0]);
// The same loop written from another corner, or the other way round, names the same
// region: a design saved that way opens instead of being refused as damaged (#6499).
for order in [["b", "c", "d", "a"], ["d", "c", "b", "a"], ["c", "d", "a", "b"]] {
let written = json!({"type":"whole_boundaries","profiles":[{"outer":order,"holes":[]}]});
let loaded: ProfileSel = serde_json::from_value(written).unwrap();
assert_eq!(loaded, canonical, "the loaded identity is canonical");
assert_eq!(profile_selection_indices(&ss, &loaded).unwrap(), vec![0]);
assert_eq!(crate::select_profiles(&ss, &loaded).unwrap().len(), 1);
}
}
fn solved_square(extra: &[(&str, [f64; 4])]) -> SolvedSketch {
let mut sketch = Sketch::new();
let corners = [Vec2::new(0., 0.), Vec2::new(10., 0.), Vec2::new(10., 10.), Vec2::new(0., 10.)];
+33 -7
View File
@@ -1046,12 +1046,29 @@ fn silhouette(plane: &Plane, m: &Mesh) -> Vec<LinkGeom> {
/// Section of a mesh (or one of its faces) with the sketch plane.
pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeom> {
let size = m.bounds().diagonal().max(1e-9);
let (segs, exact) = section_segments(plane, m, face);
let segs: Vec<(Vec2, Vec2)> = segs.into_iter().map(|(a, b, _)| (a, b)).collect();
let loose = size * 2e-3;
fit_segments(&segs, size).into_iter().map(|g| refine_round(g, &exact, loose)).collect()
}
/// The segments where the plane crosses the mesh's triangles (in plane coordinates, each with
/// its B-rep face), and the crossings that lie exactly on the surface (on a ruling).
fn section_segments(plane: &Plane, m: &Mesh, face: Option<u32>) -> (Vec<(Vec2, Vec2, u32)>, Vec<Vec2>) {
let size = m.bounds().diagonal().max(1e-9);
let q = size * 1e-7;
let key = |p: Vec3| ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64);
// Surface normal at each mesh vertex where the surface is smooth (None at creases), over
// all faces and per face.
type Key = (i64, i64, i64);
// A vertex this close to the plane is on it, and lies on the same side for every edge it
// ends (so a plane through a vertex, or on a face, cuts every edge there alike): below when
// the mesh lies on or above the plane (the cut at its bottom face), else above (at its top
// face, through an apex). An edge with an end on the plane crosses it at that end.
let snap = size * 1e-9;
let zero_above = m.positions.iter().any(|p| plane.height(*p) < -snap);
let above = |h: f64| if h.abs() <= snap { zero_above } else { h > 0.0 };
let mut normals: BTreeMap<Key, Option<Vec3>> = BTreeMap::new();
let mut face_normals: BTreeMap<(u32, Key), Option<Vec3>> = BTreeMap::new();
// Faces on either side of each mesh edge.
@@ -1084,14 +1101,25 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
let crossing = |a: Vec3, b: Vec3| -> Option<Vec3> {
let (a, b) = if key(a) <= key(b) { (a, b) } else { (b, a) };
let (ha, hb) = (plane.height(a), plane.height(b));
if (ha > 0.0) == (hb > 0.0) {
if above(ha) == above(hb) {
return None;
}
if let Some(p) = [(a, ha), (b, hb)].into_iter().find(|(_, h)| h.abs() <= snap).map(|(p, _)| p) {
exact.borrow_mut().push(plane.to_local(p));
return Some(p);
}
let d = b - a;
// An edge inside one face takes that face's normals (its ends may lie on the face's
// boundary, a crease for the whole mesh); an edge between faces, the merged ones, so
// both faces agree on the crossing.
let inside = edge_faces.get(&(key(a), key(b))).and_then(|fs| fs.first().filter(|f| fs.len() == 2 && fs.iter().all(|x| x == *f)).copied());
let faces = edge_faces.get(&(key(a), key(b)));
let inside = faces.and_then(|fs| fs.first().filter(|f| fs.len() == 2 && fs.iter().all(|x| x == *f)).copied());
// An edge where a curved face meets another (a cylinder's rim on its cap) lies on the
// curved face: its normals there put the crossing on the curve, not on the chord.
let face_normal = |f: u32, p: Vec3| face_normals.get(&(f, key(p))).copied().flatten();
let curved =
|| faces?.iter().copied().find(|f| matches!((face_normal(*f, a), face_normal(*f, b)), (Some(x), Some(y)) if x.dot(y) < 1.0 - 1e-12));
let inside = inside.or_else(curved);
let normal = |p: Vec3| match inside {
Some(f) => face_normals.get(&(f, key(p))).copied().flatten(),
None => normals.get(&key(p)).copied().flatten(),
@@ -1136,7 +1164,7 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
}
Some(h(0.5 * (lo + hi)))
};
let mut segs: Vec<(Vec2, Vec2)> = Vec::new();
let mut segs: Vec<(Vec2, Vec2, u32)> = Vec::new();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if face.is_some_and(|x| x != *f) {
continue;
@@ -1152,13 +1180,11 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
if let [a, b] = pts[..] {
let (a, b) = (plane.to_local(a), plane.to_local(b));
if a.dist(b) > size * 1e-9 {
segs.push((a, b));
segs.push((a, b, *f));
}
}
}
let exact = exact.into_inner();
let loose = size * 2e-3;
fit_segments(&segs, size).into_iter().map(|g| refine_round(g, &exact, loose)).collect()
(segs, exact.into_inner())
}
/// Re-fit a circle or arc through the exact section points near it, when there are enough.
+125
View File
@@ -103,6 +103,40 @@ fn set_back_reports_restored_features() {
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
}
/// Setting a parameter back restores every feature on a design whose states are large next to
/// the cache's budget: each cached state shares the bodies it did not change with the states
/// before it, so it is charged only for what it made, not for the whole model again.
#[test]
fn set_back_restores_everything_when_states_share_bodies() {
let mut doc = Document::new("blocks");
doc.set_param("h", "10 mm", Some("mm"), None).unwrap();
for i in 0..30 {
let height = if i == 0 { "h" } else { "10" };
doc.add_feature(
FeatureKind::Box {
corner: point_expr(Vec3::new(20.0 * i as f64, 0.0, 0.0)),
length: "10".into(),
width: "10".into(),
height: height.into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
}
let mut m = Model::new();
m.evaluate(&doc);
// Room for a few whole models: enough for both timelines' new bodies, not for every state
// charged as a whole model.
m.cache.lock().unwrap().budget = 3 * m.state().retained_bytes();
doc.set_param("h", "12 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!(m.last_recomputed, 30);
doc.set_param("h", "10 mm", None, None).unwrap();
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 30));
}
/// #1989: what an evaluation ahead on a copy of the model computed is kept for the run that
/// follows on the model itself, which finds it there even once the evaluation cache has dropped
/// it (the cache keeps only a few states of a design with large bodies). Copies keep nothing.
@@ -125,6 +159,97 @@ fn results_kept_ahead_outlive_the_cache() {
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
}
/// Rolling the marker back must not discard unchanged results just because the bounded cache
/// evicted them. Opening an earlier feature and returning to the end otherwise rebuilds it all.
#[test]
fn rolling_the_marker_back_keeps_results_after_cache_eviction() {
for reopen in [false, true] {
let (mut doc, _) = plate_doc();
let mut m = Model::new();
m.evaluate(&doc);
if reopen {
m = Model::adopt(&doc, &m.saved(&doc).unwrap()).unwrap();
}
let finished = m.state();
let computed = m.recomputed;
// Simulate eviction by previews or the cache's byte limit, without a large/slow fixture.
*m.cache.lock().unwrap() = Default::default();
for marker in [Some(1), Some(0), Some(1), None] {
doc.marker = marker;
m.evaluate(&doc);
assert_eq!(m.recomputed, computed, "reopen {reopen}, marker {marker:?}: unchanged features must be reused");
assert_eq!(m.state().bodies.len(), usize::from(marker.is_none()));
}
assert!(std::sync::Arc::ptr_eq(&m.state(), &finished), "return to the original evaluated state");
}
}
#[test]
fn rolling_a_large_unchanged_timeline_forward_reuses_its_geometry() {
let mut doc = Document::new("marker cache budget");
for i in 0..80 {
doc.add_feature(
FeatureKind::Box {
corner: point_expr(Vec3::new((i % 10) as f64 * 15.0, (i / 10) as f64 * 15.0, 0.0)),
length: "10".into(),
width: "10".into(),
height: "10".into(),
operation: Operation::NewBody,
},
None,
)
.unwrap();
}
let mut m = Model::new();
m.evaluate(&doc);
assert!(m.results.iter().all(|r| r.error.is_none()));
// Exceed the actual byte budget without manually clearing the cache.
assert!(m.results.iter().map(|r| r.state.retained_bytes()).sum::<usize>() > 64 << 20);
let finished = m.state();
doc.marker = Some(1);
m.evaluate(&doc);
assert_eq!(m.state().bodies.len(), 1);
doc.marker = None;
m.evaluate(&doc);
assert_eq!(m.last_recomputed, 0, "returning to the end must not rebuild unchanged boxes");
assert_eq!(m.state().bodies.len(), 80);
assert!(std::sync::Arc::ptr_eq(&m.state(), &finished));
}
#[test]
fn rolled_results_never_mask_edits_or_disabled_reuse() {
for change in ["parameter", "feature", "suppressed", "units", "no_cache"] {
let (mut doc, _) = plate_doc();
let mut m = Model::new();
m.evaluate(&doc);
doc.marker = Some(0);
m.evaluate(&doc);
*m.cache.lock().unwrap() = Default::default();
match change {
"parameter" => doc.set_param("width", "50 mm", None, None).unwrap(),
"feature" => {
let FeatureKind::Extrude { extent, .. } = &mut doc.features[1].kind else { panic!("extrude") };
extent.distance = "25".into();
}
"suppressed" => doc.features[1].suppressed = true,
"units" => doc.units = "in".into(),
"no_cache" => m.no_cache = true,
_ => panic!("unknown edit case"),
}
doc.marker = None;
m.evaluate(&doc);
let mut fresh = Model::new();
fresh.evaluate(&doc);
let volume = |model: &Model| model.state().bodies.iter().map(|b| measure(&b.body).unwrap().volume).sum::<f64>();
assert_eq!(m.state().bodies.len(), fresh.state().bodies.len(), "{change}");
assert!((volume(&m) - volume(&fresh)).abs() < 1e-6, "{change}: retained geometry must match a fresh evaluation");
assert_eq!(m.results.iter().map(|r| &r.error).collect::<Vec<_>>(), fresh.results.iter().map(|r| &r.error).collect::<Vec<_>>(), "{change}");
if matches!(change, "parameter" | "feature" | "units" | "no_cache") {
assert!(m.last_recomputed > 0, "{change}: changed inputs must rebuild");
}
}
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
+31 -2
View File
@@ -394,11 +394,17 @@ impl PlaneRef {
}
match self {
Self::Construction { name } => f(Ref::Feature(name)),
Self::Offset { base, .. } | Self::AtAngle { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs(f, depth + 1),
Self::Offset { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs(f, depth + 1),
Self::ThroughPoint { base, point } => {
base.walk_feature_refs(f, depth + 1);
point.walk_feature_refs(f, depth + 1);
}
Self::AtAngle { base, line, .. } => {
base.walk_feature_refs(f, depth + 1);
if let Some(g) = line {
g.walk_feature_refs(f, depth + 1);
}
}
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => {
a.walk_feature_refs(f, depth + 1);
b.walk_feature_refs(f, depth + 1);
@@ -419,11 +425,17 @@ impl PlaneRef {
}
match self {
Self::Construction { name } => f(Ref::Feature(name)),
Self::Offset { base, .. } | Self::AtAngle { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs_mut(f, depth + 1),
Self::Offset { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs_mut(f, depth + 1),
Self::ThroughPoint { base, point } => {
base.walk_feature_refs_mut(f, depth + 1);
point.walk_feature_refs_mut(f, depth + 1);
}
Self::AtAngle { base, line, .. } => {
base.walk_feature_refs_mut(f, depth + 1);
if let Some(g) = line {
g.walk_feature_refs_mut(f, depth + 1);
}
}
Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => {
a.walk_feature_refs_mut(f, depth + 1);
b.walk_feature_refs_mut(f, depth + 1);
@@ -532,6 +544,23 @@ impl Walk for AxisDef {
construct_geometry!();
}
/// A feature's own reference to the model (a revolve's or circular pattern's axis on an edge or
/// round face): walked like a construction axis's.
impl Walk for GeoRef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
plane_exprs(vec![self], f)
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
plane_exprs_mut(vec![self], f)
}
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
f(Geo::Construct(self))
}
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
f(Geo::Construct(self))
}
}
impl Walk for PointDef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
match self {
+134
View File
@@ -0,0 +1,134 @@
//! A batch's steps checked before the first one runs (#5853).
//!
//! A batch runner (the control channel's `engine.batch`, the MCP `batch` tool) runs its steps in
//! order and stops at the first failure, going back to the design as it was. A misspelled
//! command id in a late step was found only at its turn, after every earlier step had run, and
//! then all of that work was undone: a 160-step batch with a typo as its last step ran for a
//! minute and changed nothing. [`check`] finds, before anything runs, the first step the batch
//! would certainly refuse at its turn, so the runner can refuse the batch unrun:
//!
//! - a step without a command id;
//! - a command id no command has (the error names the closest ids, as at the step's turn);
//! - with the session's [`ParamCheck::Strict`], parameters that break the command's typed
//! contract (the contract check is the one the step would get; it reads only the parameters).
//!
//! Anything that depends on the design (a face that isn't there yet, an expression naming a
//! parameter an earlier step makes) is left to the step's turn, as are parameters that strict
//! checking would not refuse.
use serde_json::{Value, json};
use crate::schema::ParamCheck;
use crate::{EngineError, find_command};
/// The first step a batch would refuse at its turn.
#[derive(Clone, Debug, PartialEq)]
pub struct Refusal {
pub index: usize,
pub command: Option<String>,
pub error: String,
}
impl Refusal {
/// The batch reply for a batch refused before its first step: as a failed step's reply,
/// with nothing completed, nothing undone and every step not run.
pub fn reply(&self, steps: usize) -> Value {
json!({
"completed": 0,
"results": [],
"failed": {"index": self.index, "command": self.command, "error": self.error},
"undone": 0,
"rolled_back": false,
"not_run": (0..steps).collect::<Vec<_>>(),
"note": "nothing was run: the batch was checked before its first step",
})
}
}
/// The first of `steps` (`{command | id, params?}`) that would be refused at its turn whatever
/// the steps before it do, checked against the command registry and, at `level`
/// [`ParamCheck::Strict`], the commands' typed contracts. `host` names ids the runner answers
/// itself, outside the registry (the app's `view.pick`).
pub fn check(steps: &[Value], level: ParamCheck, host: &[&str]) -> Option<Refusal> {
for (index, step) in steps.iter().enumerate() {
let Some(id) = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str) else {
return Some(Refusal { index, command: None, error: "missing `command`".into() });
};
if host.contains(&id) {
continue;
}
let Some(spec) = find_command(id) else {
return Some(Refusal { index, command: Some(id.to_string()), error: EngineError::UnknownCommand(id.to_string()).to_string() });
};
if level == ParamCheck::Strict
&& let Some(contract) = spec.contract
&& let Some(first) = contract.check(step.get("params").unwrap_or(&Value::Null)).into_iter().next()
{
return Some(Refusal { index, command: Some(id.to_string()), error: crate::params::bad(id, first.message).to_string() });
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn step(command: &str, params: Value) -> Value {
json!({"command": command, "params": params})
}
/// 40 circles extruded, as in the issue: 160 good steps.
fn circles() -> Vec<Value> {
(0..40)
.flat_map(|k| {
[
step("sketch.create", json!({"plane": "XY"})),
step("sketch.circle.center", json!({"center": [k * 7, (k % 5) * 3], "radius": 5})),
json!({"command": "sketch.finish"}),
step("solid.extrude", json!({"distance": 20 + k, "operation": "new"})),
]
})
.collect()
}
#[test]
fn a_typo_in_the_last_step_is_found_before_the_first_runs() {
let mut steps = circles();
assert_eq!(check(&steps, ParamCheck::Off, &[]), None, "every step names a command");
steps.push(step("solid.extrud", json!({"distance": 5})));
let r = check(&steps, ParamCheck::Off, &[]).unwrap();
assert_eq!((r.index, r.command.as_deref()), (160, Some("solid.extrud")));
assert!(r.error.starts_with("unknown command `solid.extrud`") && r.error.contains("solid.extrude"), "{}", r.error);
let reply = r.reply(steps.len());
assert_eq!((reply["completed"].clone(), reply["undone"].clone(), reply["rolled_back"].clone()), (json!(0), json!(0), json!(false)));
assert_eq!(reply["not_run"].as_array().map(Vec::len), Some(161), "nothing ran");
assert_eq!(reply["failed"]["index"], 160);
}
#[test]
fn a_step_without_a_command_and_the_first_bad_step_are_named() {
let steps = vec![step("solid.box", json!({"length": 1, "width": 1, "height": 1})), json!({"params": {}}), step("nope.nope", json!({}))];
assert_eq!(check(&steps, ParamCheck::Off, &[]), Some(Refusal { index: 1, command: None, error: "missing `command`".into() }));
// `id` is read as `command` is, and an id the runner answers itself passes.
let steps = vec![json!({"id": "solid.box", "params": {"length": 1, "width": 1, "height": 1}}), step("view.pick", json!({"x": 1, "y": 1}))];
assert_eq!(check(&steps, ParamCheck::Off, &["view.pick"]), None);
assert_eq!(check(&steps, ParamCheck::Off, &[]).map(|r| r.index), Some(1), "outside the registry without the runner");
}
#[test]
fn strict_checking_refuses_a_broken_contract_before_the_first_step() {
let steps =
vec![step("solid.box", json!({"length": 1, "width": 1, "height": 1})), step("solid.box", json!({"lenght": 1, "width": 1, "height": 1}))];
// Off and warn run such a step (warn reports it): nothing to refuse up front.
assert_eq!(check(&steps, ParamCheck::Off, &[]), None);
assert_eq!(check(&steps, ParamCheck::Warn, &[]), None);
let r = check(&steps, ParamCheck::Strict, &[]).unwrap();
assert_eq!(r.index, 1);
assert!(r.error.contains("solid.box") && r.error.contains("lenght"), "{}", r.error);
// The same call alone is refused with the same words at its turn.
let mut s = crate::Session { param_check: ParamCheck::Strict, ..crate::Session::default() };
let at_turn = s.execute("solid.box", &steps[1]["params"]).unwrap_err().to_string();
assert_eq!(r.error, at_turn);
}
}
+27 -23
View File
@@ -1,6 +1,7 @@
//! Part properties and the bill of materials. `component.metadata` and
//! `component.metadata.set` read and change a component definition's part number, description,
//! revision and free properties (shared by every occurrence, saved with the component);
//! revision and free properties (shared by every occurrence, saved with the component; the keys
//! and their checks are `solidcraft_doc::metadata`'s `KEYS`, the rows a component takes);
//! `assembly.bom` counts the placed components of the design, or of one component, and lists
//! them as rows, optionally written to a CSV or JSON file. With `group_children`, a part with a
//! part number keeps the parts placed inside it (a bolt's washers and nut) in its own row.
@@ -8,7 +9,7 @@
use std::collections::{BTreeMap, HashSet};
use serde_json::{Map, Value, json};
use solidcraft_doc::metadata::{DESCRIPTION, MAX_KEYS, MAX_TEXT_BYTES, PART_NUMBER, REVISION, WELL_KNOWN};
use solidcraft_doc::metadata::{BOM_KEYS, MAX_KEYS, Owner, check_text, key_list};
use super::CommandSpec;
use crate::params::{bad, str_};
@@ -21,9 +22,9 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("component.metadata.set", "Change Part Properties", set).icon("settings").doc_only().params(
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
removes); values?: {key: value} (SolidCraft's keys project, part_number, revision, author, organization, description, \
license, or a reverse-DNS key holding an object); properties?: {name: text, …} (free properties; empty or null removes). \
All or nothing, one undo step → component, metadata, properties, changed: [keys]",
removes); values?: {key: value} (SolidCraft's keys, as document.metadata's properties lists them, or a reverse-DNS key \
holding an object); properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step \
→ component, metadata, properties, changed: [keys]",
),
CommandSpec::new("assembly.bom", "Bill of Materials", bom).at("SOLID", "INSPECT").icon("bom").noundo().params(
"component?: id or name (default the design); levels?: flat (default: every part at any depth, counted through its \
@@ -58,15 +59,22 @@ fn component_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
fn read(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.metadata";
let id = component_id(s, p, cmd)?;
if id == 0 {
let m = &s.doc.metadata;
return Ok(json!({"component": 0, "name": s.doc.name, "part_number": m.part_number(), "description": m.description(),
"revision": m.revision(), "metadata": m, "properties": {}}));
// The root is the design: its own properties, and no free ones.
let (name, m, properties) = if id == 0 {
(&s.doc.name, &s.doc.metadata, json!({}))
} else {
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
(&c.name, &c.metadata, json!(c.properties))
};
let mut out = Map::new();
out.insert("component".into(), json!(id));
out.insert("name".into(), json!(name));
for k in BOM_KEYS {
out.insert(k.to_string(), json!(m.text(k)));
}
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
let m = &c.metadata;
Ok(json!({"component": id, "name": c.name, "part_number": m.part_number(), "description": m.description(),
"revision": m.revision(), "metadata": m, "properties": c.properties}))
out.insert("metadata".into(), json!(m));
out.insert("properties".into(), properties);
Ok(Value::Object(out))
}
/// A free property as it may be saved (`None`: remove it), or why not.
@@ -83,12 +91,8 @@ fn property(name: &str, value: &Value, cmd: &str) -> Result<Option<String>> {
Value::Bool(b) => b.to_string(),
_ => return Err(bad(cmd, format!("property `{shown}` takes text"))),
};
if text.len() > MAX_TEXT_BYTES {
return Err(bad(cmd, format!("property `{shown}` is longer than {MAX_TEXT_BYTES} bytes")));
}
if text.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
return Err(bad(cmd, format!("property `{shown}` has a control character")));
}
// The same text rule as SolidCraft's keys.
check_text(&format!("property `{shown}`"), &text).map_err(|e| bad(cmd, e.to_string()))?;
Ok(Some(text))
}
@@ -107,8 +111,8 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Object(_)) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
Some(_) => return Err(bad(cmd, "`values` must be an object: {key: value, …}")),
}
for k in [PART_NUMBER, DESCRIPTION, REVISION] {
if let Some(v) = p.get(k) {
for k in BOM_KEYS {
if let Some(v) = p.get(*k) {
entries.push((k.to_string(), v.clone()));
}
}
@@ -119,11 +123,11 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) => return Err(bad(cmd, "`properties` must be an object: {name: text, …}")),
};
if entries.is_empty() && props.is_empty() {
return Err(bad(cmd, format!("give part_number, description, revision, values or properties ({})", WELL_KNOWN.join(", "))));
return Err(bad(cmd, format!("give {}, values or properties ({})", BOM_KEYS.join(", "), key_list(Owner::Component))));
}
let mut changed: Vec<String> = Vec::new();
for (k, v) in entries {
if metadata.set(&k, v).map_err(|e| bad(cmd, e.to_string()))? && !changed.contains(&k) {
if metadata.set(Owner::Component, &k, v).map_err(|e| bad(cmd, e.to_string()))? && !changed.contains(&k) {
changed.push(k);
}
}
+122
View File
@@ -122,6 +122,61 @@ fn part_properties_survive_save_and_open_and_undo_in_one_step() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn inserted_design_carries_its_part_properties_into_the_bom() {
let dir = scratch("insert-design");
let source = dir.join("plate.solidcraft");
let metadata = json!({
"part_number": "PL-6624", "description": "Mounting plate", "revision": "B",
"author": "Example author", "license": "CC0-1.0", "com.example.catalog": {"stock": 12}
});
let mut part = Session::default();
run(&mut part, "solid.box", json!({"length": 40, "width": 20, "height": 3}));
run(&mut part, "document.metadata.set", json!({"values": metadata}));
run(&mut part, "file.save", json!({"path": source}));
for reuse in [false, true] {
let mut s = Session::default();
run(&mut s, "document.metadata.set", json!({"values": {"part_number": "ASM-1", "description": "Assembly"}}));
let own_metadata = s.doc.metadata.clone();
let undo = s.undo.len();
let inserted = run(&mut s, "component.insert_design", json!({"path": source, "name": "Plate", "reuse": reuse}));
let component = inserted["component"].as_u64().unwrap();
assert_eq!(run(&mut s, "component.metadata", json!({"component": component}))["metadata"], metadata);
assert_eq!(s.doc.metadata, own_metadata, "the assembly keeps its own properties");
let bom = run(&mut s, "assembly.bom", json!({}));
assert_eq!(bom["rows"][0]["part_number"], "PL-6624");
assert_eq!(bom["rows"][0]["description"], "Mounting plate");
assert_eq!(bom["rows"][0]["revision"], "B");
assert_eq!(bom["rows"][0]["count"], 1);
assert_eq!(s.undo.len(), undo + 1, "insertion including properties is one undo step");
run(&mut s, "edit.undo", json!({}));
assert!(s.doc.components.is_empty());
assert!(s.doc.links.is_empty());
run(&mut s, "edit.redo", json!({}));
assert_eq!(run(&mut s, "component.metadata", json!({"component": component}))["metadata"], metadata);
for (file, format) in [("assembly.json", "json"), ("assembly.solidcraft", "container")] {
let path = dir.join(file);
run(&mut s, "file.save_as", json!({"path": path, "format": format}));
let mut back = Session::default();
run(&mut back, "doc.open", json!({"path": path}));
assert_eq!(run(&mut back, "component.metadata", json!({"component": component}))["metadata"], metadata, "{format}");
assert_eq!(run(&mut back, "assembly.bom", json!({}))["rows"], bom["rows"], "{format}");
}
// Reusing the link shares its definition, including locally edited part properties.
run(&mut s, "component.metadata.set", json!({"component": component, "description": "Assembly-specific description"}));
run(&mut s, "component.insert_design", json!({"path": source, "reuse": true, "translate": [50, 0, 0]}));
let bom = run(&mut s, "assembly.bom", json!({}));
assert_eq!(bom["rows"].as_array().unwrap().len(), 1);
assert_eq!(bom["rows"][0]["count"], 2);
assert_eq!(bom["rows"][0]["part_number"], "PL-6624");
assert_eq!(bom["rows"][0]["description"], "Assembly-specific description");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn csv_quotes_every_field_and_json_writes_the_rows() {
let dir = scratch("export");
@@ -203,6 +258,73 @@ fn hostile_part_properties_are_refused_without_a_change() {
assert_eq!(run(&mut s, "component.metadata", json!({"component": 0}))["part_number"], "ASM-1");
}
#[test]
fn a_components_refusals_speak_of_part_properties() {
let mut s = assembly();
let undo = s.undo.len();
let refusal = |s: &mut Session, p: Value| s.execute("component.metadata.set", &p).unwrap_err().to_string();
let bogus = refusal(&mut s, json!({"component": "Pin", "values": {"bogus": "x"}}));
assert!(
bogus.ends_with(
"`bogus` is not a part property: SolidCraft's are project, part_number, revision, author, organization, description, \
license; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
),
"{bogus}"
);
assert!(!bogus.contains("design property"), "{bogus}");
assert!(refusal(&mut s, json!({"component": "Pin", "values": {"": "x"}})).contains("`` is not a part property"));
// Nothing to change: the same list as before the table.
assert!(refusal(&mut s, json!({"component": "Pin"})).ends_with(
"give part_number, description, revision, values or properties (project, part_number, revision, author, organization, \
description, license)"
));
// Only the bill of materials' keys are parameters of their own; the others go in `values`.
assert!(refusal(&mut s, json!({"component": "Pin", "author": "Jane"})).contains("give part_number, description, revision"));
let long = refusal(&mut s, json!({"component": "Pin", "properties": {"finish": "x".repeat(5000)}}));
assert!(long.ends_with("property `finish` is longer than 4096 bytes"), "{long}");
let bell = refusal(&mut s, json!({"component": "Pin", "properties": {"finish": "a\u{7}"}}));
assert!(bell.ends_with("property `finish` has a control character"), "{bell}");
assert_eq!(s.undo.len(), undo, "nothing changed");
// The design keeps its own words.
let design = s.execute("document.metadata.set", &json!({"key": "bogus", "value": "x"})).unwrap_err().to_string();
assert!(design.contains("`bogus` is not a design property"), "{design}");
}
#[test]
fn every_key_a_component_took_before_is_still_taken() {
let mut s = assembly();
let keys = ["project", "part_number", "revision", "author", "organization", "description", "license"];
let mut values: serde_json::Map<String, Value> = keys.iter().map(|k| (k.to_string(), json!(format!("{k} text")))).collect();
values.insert("com.acme.shop".into(), json!({"id": 7}));
let r = run(&mut s, "component.metadata.set", json!({"component": "Plate", "values": values}));
assert_eq!(r["changed"].as_array().map(Vec::len), Some(keys.len() + 1), "{r}");
let plate = run(&mut s, "component.metadata", json!({"component": "Plate"}));
for k in keys {
assert_eq!(plate["metadata"][k], format!("{k} text"), "{k}");
}
for k in ["part_number", "description", "revision"] {
assert_eq!(plate[k], format!("{k} text"), "the bill of materials' field {k}: {plate}");
}
assert!(plate.get("author").is_none(), "the others stay in metadata: {plate}");
// The shortcuts and the root read the same fields.
run(&mut s, "component.metadata.set", json!({"component": "Pin", "part_number": "", "description": "Pin", "revision": "D"}));
let pin = run(&mut s, "component.metadata", json!({"component": "Pin"}));
assert_eq!((&pin["part_number"], &pin["description"], &pin["revision"]), (&Value::Null, &json!("Pin"), &json!("D")), "{pin}");
let root = run(&mut s, "component.metadata", json!({"component": 0}));
assert_eq!((&root["component"], &root["part_number"], &root["properties"]), (&json!(0), &Value::Null, &json!({})), "{root}");
}
#[test]
fn a_component_refused_past_its_limits_says_part_properties() {
let mut s = assembly();
// The pin holds a part number and a description: room for this many more keys.
let room = solidcraft_doc::metadata::MAX_KEYS - 2;
let values: serde_json::Map<String, Value> = (0..room).map(|i| (format!("org.k{i}"), json!({}))).collect();
run(&mut s, "component.metadata.set", json!({"component": "Pin", "values": values}));
let over = s.execute("component.metadata.set", &json!({"component": "Pin", "values": {"org.one_more": {}}})).unwrap_err().to_string();
assert!(over.ends_with("part properties are limited to 256 keys and 256 KiB"), "{over}");
}
/// Two hex bolts, each with a washer and a nut placed inside it, and a third bolt alone.
fn bolts() -> Session {
let mut s = Session::default();
+102 -12
View File
@@ -367,7 +367,10 @@ fn visibility(s: &mut Session, p: &Value) -> Result<Value> {
}
let v = &mut s.visibility;
match str_(p, "folder") {
Some("bodies" | "all") if visible => v.hidden_occurrences.clear(),
Some("bodies" | "all") if visible => {
v.hidden_bodies.clear();
v.hidden_occurrences.clear();
}
// Showing a whole folder shows the folder too.
Some("origin") if visible => v.origin_hidden = Some(false),
_ => {}
@@ -760,6 +763,7 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
if name == old {
return Ok(json!({"body": old, "name": name}));
}
let previous = s.doc.clone();
let st = s.model.state();
let b = st.body(&old).ok_or_else(|| bad(cmd, format!("no body `{old}`")))?;
if st.body(name).is_some() {
@@ -819,7 +823,7 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
*names = pins;
f.body_names.clear();
} else {
f.body_names.clone_from(made);
f.pin_body_names(made);
}
}
}
@@ -828,15 +832,18 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
*names = pins;
f.body_names.clear();
} else {
while f.body_names.len() < siblings.len().max(k + 1) {
let i = f.body_names.len();
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
// An Import keeps its names by the file's body index, its skipped bodies' too (#2175).
let slots = f.body_name_slots(siblings.len().max(k + 1));
if let Some(end) = slots.last().map(|s| s + 1).filter(|end| f.body_names.len() < *end) {
f.body_names.resize(end, String::new());
}
for (slot, current) in f.body_names.iter_mut().zip(&siblings) {
slot.clone_from(current);
for (slot, current) in slots.iter().zip(&siblings) {
if let Some(stored) = f.body_names.get_mut(*slot) {
stored.clone_from(current);
}
}
if let Some(slot) = f.body_names.get_mut(k) {
*slot = name.to_string();
if let Some(stored) = slots.get(k).and_then(|slot| f.body_names.get_mut(*slot)) {
*stored = name.to_string();
}
}
// Later features refer to bodies by name.
@@ -875,10 +882,10 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, e));
}
d.rename_body_keys(&old, name);
let v = &mut s.visibility;
for b in v.hidden_bodies.iter_mut().chain(v.locked_bodies.iter_mut()).filter(|b| **b == old) {
for b in s.visibility.locked_bodies.iter_mut().filter(|b| **b == old) {
*b = name.to_string();
}
s.remember_body_rename(&previous, &old, name);
Ok(json!({"body": old, "name": name}))
}
@@ -1008,6 +1015,88 @@ mod tests {
s.model.state().bodies.iter().map(|b| b.name.clone()).collect()
}
#[test]
fn body_rename_preserves_plastic_rules_and_current_visibility_through_history() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Plate"}));
run(&mut s, "plastic.manage_rules", json!({"name": "Housing", "draft": "3 deg"}));
run(&mut s, "plastic.assign_rule", json!({"bodies": ["Plate"], "rule": "Housing"}));
run(&mut s, "browser.visibility", json!({"items": ["Plate"], "visible": false}));
run(&mut s, "body.rename", json!({"body": "Plate", "name": "Base"}));
assert_eq!(run(&mut s, "plastic.rules", json!({}))["assigned"], json!({"Base": "Housing"}));
assert!(!s.body_visible("Base"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "plastic.rules", json!({}))["assigned"], json!({"Plate": "Housing"}));
assert!(!s.body_visible("Plate"), "undo must keep the body hidden");
run(&mut s, "edit.redo", json!({}));
assert!(!s.body_visible("Base"));
// Showing a body is its own undo step; the rename keeps the correct hidden name on
// either side of that step, and neither operation loses the assigned rule.
run(&mut s, "browser.visibility", json!({"items": ["Base"], "visible": true}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), ["Base"]);
assert!(!s.body_visible("Base"));
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), ["Plate"]);
assert!(!s.body_visible("Plate"));
run(&mut s, "edit.redo", json!({}));
assert!(!s.body_visible("Base"));
run(&mut s, "edit.redo", json!({}));
assert!(s.body_visible("Base"));
run(&mut s, "browser.visibility", json!({"items": ["Base"], "visible": false}));
let mut saved = (*s.doc).clone();
saved.visibility = s.visibility.clone();
let reopened = Session::new(serde_json::from_str(&serde_json::to_string(&saved).unwrap()).unwrap());
assert_eq!(reopened.doc.plastic.assigned.get("Base").map(String::as_str), Some("Housing"));
assert!(!reopened.body_visible("Base"));
}
#[test]
fn body_rename_undo_keeps_hidden_bodies_with_downstream_users_hidden() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Peg"}));
run(&mut s, "solid.move", json!({"bodies": ["Peg"], "translate": [0, 0, 5]}));
run(&mut s, "browser.visibility", json!({"items": ["Peg"], "visible": false}));
run(&mut s, "body.rename", json!({"body": "Peg", "name": "Stud"}));
run(&mut s, "edit.undo", json!({}));
assert!(!s.body_visible("Peg"));
assert_eq!(s.visibility.hidden_bodies, ["Peg"]);
run(&mut s, "edit.redo", json!({}));
assert!(!s.body_visible("Stud"));
}
#[test]
fn show_all_bodies_clears_stale_hidden_names() {
for folder in ["bodies", "all"] {
let mut s = Session::default();
s.visibility.hidden_bodies.push("Old body name".into());
run(&mut s, "browser.visibility", json!({"folder": folder, "visible": true}));
assert!(s.visibility.hidden_bodies.is_empty(), "{folder}");
}
}
#[test]
fn computed_body_rename_keeps_the_latest_visibility_and_undo_mapping() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Peg"}));
run(&mut s, "solid.move", json!({"bodies": ["Peg"], "translate": [0, 0, 5]}));
let mut computed = s.scratch();
run(&mut computed, "body.rename", json!({"body": "Peg", "name": "Stud"}));
// The user hides the body while the rename is being evaluated.
run(&mut s, "browser.visibility", json!({"items": ["Peg"], "visible": false}));
s.accept_computed(computed);
assert!(!s.body_visible("Stud"));
assert_eq!(s.visibility.hidden_bodies, ["Stud"]);
run(&mut s, "edit.undo", json!({}));
assert!(!s.body_visible("Peg"));
run(&mut s, "edit.undo", json!({}));
assert!(s.body_visible("Peg"), "the concurrent hide remains its own undo step");
run(&mut s, "edit.redo", json!({}));
assert!(!s.body_visible("Peg"));
run(&mut s, "edit.redo", json!({}));
assert!(!s.body_visible("Stud"));
}
#[test]
fn renamed_body_keeps_its_users() {
let mut s = Session::default();
@@ -1206,8 +1295,9 @@ mod tests {
assert!(["Body1", "Body2", "Body3", "Body4", "Body5"].iter().all(|n| before.contains(&n.to_string())), "{before:?}");
run(&mut s, "solid.combine", json!({"target": "Body3", "tools": ["Body5"], "operation": "join", "name": "Joined"}));
// Renaming a body that already has a name of its own stores nothing elsewhere.
let stored = s.doc.find_feature("P2").unwrap().body_names.clone();
run(&mut s, "body.rename", json!({"body": "Second", "name": "Row"}));
assert!(s.doc.find_feature("P2").unwrap().body_names.is_empty());
assert_eq!(s.doc.find_feature("P2").unwrap().body_names, stored);
run(&mut s, "body.rename", json!({"body": "Body1", "name": "FirstCopyA"}));
run(&mut s, "body.rename", json!({"body": "Body2", "name": "FirstCopyB"}));
assert!(
+5 -2
View File
@@ -350,6 +350,11 @@ fn a_body_that_changed_shape_is_measured_again() {
/// ring under it. With a fillet under the head against the bore's sharp edge, the fillet's ring
/// is in the flange (about 0.49 mm³), next to walls that lie on one another: the boolean tried
/// for minutes there and gave up; now it is reported as such within seconds.
///
/// The clock is not read: what is under test is the result, and the boolean that gives up is
/// reported as such instead of hanging the test. The wall that lies on the other one is what
/// makes the pair unchecked, and that is what the assertions below check (#2999 dropped the clock
/// from the sibling tests the same way).
#[test]
fn a_pin_seated_line_to_line_in_its_bushing_is_measured() {
let ring = std::f64::consts::PI * (7.0f64.powi(2) - 4.0f64.powi(2)) * 0.2;
@@ -365,9 +370,7 @@ fn a_pin_seated_line_to_line_in_its_bushing_is_measured() {
run(&mut s, "solid.fillet", json!({"edges": [[104, 0, z]], "radius": 0.3, "body": "Pin"}));
run(&mut s, "solid.fillet", json!({"edges": [[107, 0, z + 1.5]], "radius": 0.5, "body": "Pin"}));
}
let started = std::time::Instant::now();
let r = run(&mut s, "inspect.clearance", json!({"bodies": ["Pin"], "against": ["Bush"], "min_volume": 0.01, "clearance": 1e-6}));
assert!(started.elapsed().as_secs_f64() < 30.0, "sunk {sunk}: {:?}", started.elapsed());
if fillet {
assert!(r["unchecked"][0]["reason"].as_str().is_some_and(|w| w.contains("pin on one cylinder")), "{r}");
continue;
+2 -2
View File
@@ -566,7 +566,7 @@ pub(super) fn extract(s: &mut Session, p: &Value) -> Result<Value> {
// This design: the component's history becomes an Import feature of the new design's bodies
let shown = file.display().to_string();
let (_, sf, faces) = session_bodies(&part, &shown)?;
let (_, sf, faces, _) = session_bodies(&part, &shown)?;
// The link has a body per piece: a body in pieces keeps its name on the first
let mut solids: Vec<String> = Vec::new();
for b in part.world_state().bodies.iter().filter(|b| !b.body.is_mesh()) {
@@ -592,7 +592,7 @@ pub(super) fn extract(s: &mut Session, p: &Value) -> Result<Value> {
f.link_faces = faces;
}
let path = stored(s.path.as_deref(), &file);
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id });
s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: id });
s.refresh();
rename_joint_faces(s, &state, id, &sc.own);
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
+266 -21
View File
@@ -2,9 +2,9 @@
//!
//! The linked design is opened and evaluated on its own, and its solid bodies, as placed in it,
//! join a new component through an Import feature (exact B-rep, kept in this design so it opens
//! without the part file). The link remembers the file, relative to this design's folder once it
//! is saved; Update Links reads the file again and replaces the Import feature's bodies, and
//! Break Link keeps the bodies and forgets the file. The bodies' faces are named after the names
//! without the part file). The link remembers the absolute file in a session and a path relative
//! to the design's folder on disk; Update Links reads it again and replaces the Import feature's
//! bodies, and Break Link keeps the bodies and forgets the file. The bodies' faces are named after the names
//! the design gives them (`Feature::link_faces`), so a joint on a linked face stays on that face
//! when the design changes, as it would inside the design, and reports it when the face is gone.
@@ -27,7 +27,7 @@ mod relink_tests;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.insert_design", "Insert Design", insert_design).at("SOLID", "ASSEMBLE").icon("component").params(
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design and a copy of its design properties; \
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]; \
reuse?: bool (default false): insert a free occurrence, reusing an existing link to the same file when available",
),
@@ -54,10 +54,10 @@ pub static COMMANDS: &[CommandSpec] = &[
(its link here, else a new linked component) → each component's history goes and its placements become placements of the linked \
component; refused when its bodies are not the same as the link's; returns component, name, replaced and warnings",
),
CommandSpec::new("component.links", "List Links", list_links)
.icon("chain")
.noundo()
.params("→ linked components: component, name, path, file (resolved), found"),
CommandSpec::new("component.links", "List Links", list_links).icon("chain").noundo().params(
"→ linked components: component, name, path, file (resolved), found (null when the file is on a network location not allowed, \
or names a device: it is not looked for), and then host (the network location) and reason",
),
];
/// The folder relative link paths are kept against: the design's own, once it has a file.
@@ -119,14 +119,37 @@ fn resolve(design: Option<&str>, link: &str) -> PathBuf {
}
}
/// Why the file a stored link names may not be reached, from its text alone (`None`: it may): a
/// device, or a network location not allowed (`vfs::design_path_refusal`). Both the link as
/// stored and the file it resolves to are judged, before anything asks the disk or the network.
fn link_refusal(design: Option<&str>, link: &str) -> Option<solidcraft_io::vfs::Remote> {
let file = resolve(design, link);
solidcraft_io::vfs::design_path_refusal(link, design).or_else(|| solidcraft_io::vfs::design_path_refusal(&file.to_string_lossy(), design))
}
/// The error for a link [`link_refusal`] refuses.
fn link_refused(design: Option<&str>, link: &str) -> Result<()> {
solidcraft_io::vfs::check_design_path(link, design).map_err(|e| EngineError::Other(e.to_string()))?;
let file = resolve(design, link);
solidcraft_io::vfs::check_design_path(&file.to_string_lossy(), design).map_err(|e| EngineError::Other(e.to_string()))
}
/// The links of `doc` stored for a design saved at `to` instead of at `from`.
pub(super) fn rebased(doc: &Document, from: Option<&str>, to: &str) -> std::collections::BTreeMap<u64, DesignLink> {
doc.links.iter().map(|(c, l)| (*c, DesignLink { path: stored(Some(to), &resolve(from, &l.path)), feature: l.feature })).collect()
}
/// Resolve a design's component links against its original file, before opening it from a
/// different location (for example a recovery file) or keeping it in session history.
pub fn resolve_design_links(doc: &mut Document, from: Option<&str>) {
for link in doc.links.values_mut() {
link.path = absolute(&resolve(from, &link.path)).to_string_lossy().into_owned();
}
}
/// The bodies of a linked design: its name, the Import feature that holds them and the names
/// the design gives their faces (`Feature::link_faces`).
type Linked = (String, solidcraft_io::StepFeature, Vec<Vec<String>>);
/// the design gives their faces (`Feature::link_faces`), with the source's design properties.
type Linked = (String, solidcraft_io::StepFeature, Vec<Vec<String>>, solidcraft_doc::Metadata);
/// The solid bodies of the design at `file`, evaluated, as an Import feature.
fn linked_bodies(file: &Path, own: Option<&str>) -> Result<Linked> {
@@ -134,9 +157,14 @@ fn linked_bodies(file: &Path, own: Option<&str>) -> Result<Linked> {
if own.is_some_and(|o| absolute(Path::new(o)) == absolute(file)) {
return Err(EngineError::Other(format!("{shown}: a design can't be linked into itself")));
}
let bytes = solidcraft_io::vfs::read(&shown, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
// A network location only when its server is allowed, a device never: judged from the text
// before anything is read (on Windows, reading `\\host\share\x` connects to the host).
let bytes = solidcraft_io::vfs::read_design_path(&shown, own, solidcraft_io::MAX_DESIGN_BYTES).map_err(|e| {
let e = e.to_string();
EngineError::Other(if e.starts_with(&shown) { e } else { format!("{shown}: {e}") })
})?;
let doc = solidcraft_io::read_design(&bytes).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
session_bodies(&Session::new(doc), &shown)
session_bodies(&Session::opened_at(doc, None, Some(&shown)), &shown)
}
/// The solid bodies of `part`, the design saved as `shown`, as an Import feature.
@@ -159,7 +187,7 @@ fn session_bodies(part: &Session, shown: &str) -> Result<Linked> {
// The STEP text keeps no face names: carry the design's own, so references into the linked
// bodies follow them as they do inside the design (#3330).
let faces = solidcraft_doc::naming::carried_face_names(&world, &feature.import.bodies);
Ok((name, feature, faces))
Ok((name, feature, faces, part.doc.metadata.clone()))
}
fn component_key(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<u64> {
@@ -178,6 +206,8 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
None => solidcraft_geom::Vec3::ZERO,
Some(v) => vec3(v).filter(|t| t.is_finite() && t.len() < 1e9).ok_or_else(|| bad(cmd, "`translate` must be [x, y, z] in mm"))?,
};
// A network or device path is judged from its text, as given, before anything reads it.
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
let file = absolute(Path::new(given));
if s.path.as_deref().is_some_and(|own| absolute(Path::new(own)) == file) {
return Err(bad(cmd, "a design can't be linked into itself"));
@@ -189,7 +219,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
return super::component::copy_occurrence(s, &json!({"component": component, "parent": parent, "translate": translate}));
}
}
let (design_name, sf, faces) = linked_bodies(&file, s.path.as_deref())?;
let (design_name, sf, faces, metadata) = linked_bodies(&file, s.path.as_deref())?;
let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name);
let mut create = json!({"name": name, "activate": false});
// Clipboard insertion uses reuse even for the first paste, which must stay movable.
@@ -202,6 +232,10 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
let made = super::component::new_component(s, &create)?;
let comp = made["component"].as_u64().ok_or_else(|| bad(cmd, "the component was not created"))?;
let occ = made["occurrence"].as_u64();
// Seed only a new definition: reused links keep any locally edited part properties.
if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == comp) {
c.metadata = metadata;
}
let id = s.doc_mut().add_feature(sf.kind, Some(&name))?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.component = comp;
@@ -212,7 +246,7 @@ fn insert_design(s: &mut Session, p: &Value) -> Result<Value> {
o.transform[3] = [translate.x, translate.y, translate.z, 1.0];
}
let path = stored(s.path.as_deref(), &file);
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: id });
s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: id });
s.refresh();
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
@@ -251,8 +285,9 @@ fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
));
};
let old = old.clone();
link_refused(s.path.as_deref(), &link.path).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
let file = resolve(s.path.as_deref(), &link.path);
let (_, sf, faces) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, format!("{cname}: {e}")))?;
warnings.extend(sf.warnings.iter().cloned());
let same = matches!(&sf.kind, FeatureKind::Import { step, .. } if *step == old);
let kept = s.doc.feature(link.feature).map(|f| &f.link_faces);
@@ -322,6 +357,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
if !file.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("solidcraft")) {
return Err(bad(cmd, "choose a saved .solidcraft design"));
}
// A network or device path is judged from its text before the disk is asked anything.
link_refused(s.path.as_deref(), given).map_err(|e| bad(cmd, e.to_string()))?;
// The ordinary lexical check in linked_bodies also works in the browser VFS. On native
// hosts, reject an alias of this assembly too (a symlink or a path with different case).
if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize))
@@ -331,7 +368,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
}
// Read/evaluate the candidate before changing anything. Session::execute owns the single
// undo step and restores both the document and evaluated state on any later failure.
let (_, sf, faces) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
let (_, sf, faces, _) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
let geometry_changed = match (s.doc.feature(link.feature).map(|f| &f.kind), &sf.kind) {
(Some(FeatureKind::Import { step: old, .. }), FeatureKind::Import { step, .. }) => old != step,
_ => true,
@@ -354,7 +391,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
f.kind = sf.kind;
f.link_faces = faces;
}
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: link.feature });
s.doc_mut().links.insert(comp, DesignLink { path: file.to_string_lossy().into_owned(), feature: link.feature });
s.refresh();
if let Some(error) = s.model.result(link.feature).and_then(|r| r.error.clone()) {
return Err(bad(cmd, error));
@@ -367,7 +404,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
for (_, name, why) in sol.errors.iter().chain(&sol.conflicts) {
warnings.push(format!("joint {name}: {why}"));
}
Ok(json!({"component": comp, "feature": link.feature, "path": path, "previous_path": link.path, "geometry_changed": geometry_changed,
let previous_path = stored(s.path.as_deref(), &resolve(s.path.as_deref(), &link.path));
Ok(json!({"component": comp, "feature": link.feature, "path": path, "previous_path": previous_path, "geometry_changed": geometry_changed,
"warnings": warnings, "joint_errors": diagnostics(&sol.errors), "joint_conflicts": diagnostics(&sol.conflicts)}))
}
@@ -375,7 +413,7 @@ fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.break_link";
let comp = component_key(s, p, "component", cmd)?;
let link = s.doc_mut().links.remove(&comp).ok_or_else(|| bad(cmd, "that component is not linked to a design"))?;
Ok(json!({"component": comp, "path": link.path}))
Ok(json!({"component": comp, "path": stored(s.path.as_deref(), &resolve(s.path.as_deref(), &link.path))}))
}
fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -386,8 +424,18 @@ fn list_links(s: &mut Session, _p: &Value) -> Result<Value> {
.map(|(c, l)| {
let file = resolve(s.path.as_deref(), &l.path);
let name = s.doc.components.iter().find(|x| x.id == *c).map(|x| x.name.clone());
let path = stored(s.path.as_deref(), &file);
let file = file.to_string_lossy();
json!({"component": c, "name": name, "path": l.path, "file": file, "found": solidcraft_io::vfs::is_file(&file)})
// A refused path is not looked for at all: on Windows asking whether a file on
// `\\host\share` exists already connects to the host.
match link_refusal(s.path.as_deref(), &l.path) {
None => json!({"component": c, "name": name, "path": path, "file": file,
"found": solidcraft_io::vfs::design_path_is_file(&file, s.path.as_deref())}),
Some(solidcraft_io::vfs::Remote::Network(host)) => json!({"component": c, "name": name, "path": path, "file": file,
"found": null, "host": host, "reason": format!("on the network location \\\\{host}, which is not allowed")}),
Some(solidcraft_io::vfs::Remote::Device) => json!({"component": c, "name": name, "path": path, "file": file,
"found": null, "reason": "names a device, not a file"}),
}
})
.collect();
Ok(json!({"links": links}))
@@ -436,6 +484,95 @@ mod tests {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
/// #6860: Save As changes the file's folder, not the targets kept in the edit history.
#[test]
fn save_as_keeps_link_targets_through_undo_redo_and_checkpoints() {
for on_copy in [false, true] {
let dir = std::env::temp_dir().join(format!("solidcraft-link-save-as-{}-{on_copy}", std::process::id()));
std::fs::create_dir_all(dir.join("parts")).unwrap();
std::fs::create_dir_all(dir.join("asm/sub/deeper")).unwrap();
let part = dir.join("parts/plate.solidcraft");
let case = dir.join("asm/case.solidcraft");
save_box(&part, 30.0);
let mut s = Session::default();
run(&mut s, "component.insert_design", json!({"path": part}));
run(&mut s, "file.save", json!({"path": case}));
// Opening starts from the relative paths actually written to disk.
run(&mut s, "doc.open", json!({"path": case}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [60, 0, 0]}));
let checkpoint = s.checkpoint();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [80, 0, 0]}));
run(&mut s, "edit.undo", json!({}));
let before = s.doc.clone();
let undo_before = s.undo.last().unwrap().doc.clone();
let missing = dir.join("missing/case.solidcraft");
assert!(s.execute("file.save_as", &json!({"path": missing})).is_err());
assert_eq!(s.path.as_deref(), case.to_str());
for folder in ["asm/sub", "asm/sub/deeper"] {
let path = dir.join(folder).join("case.solidcraft");
if on_copy {
let mut copy = s.scratch();
run(&mut copy, "file.save_as", json!({"path": path}));
s.adopt_save(&copy);
} else {
run(&mut s, "file.save_as", json!({"path": path}));
}
assert!(!s.is_dirty());
for command in ["edit.redo", "edit.undo", "edit.undo", "edit.redo"] {
run(&mut s, command, json!({}));
let links = run(&mut s, "component.links", json!({}));
assert_eq!(links["links"][0]["found"], true, "{on_copy}, {folder}, {command}: {links}");
assert_eq!(absolute(Path::new(links["links"][0]["file"].as_str().unwrap())), absolute(&part));
}
}
assert!(std::sync::Arc::ptr_eq(&s.doc, &before), "saving does not rewrite the live document");
assert!(std::sync::Arc::ptr_eq(&s.undo.last().unwrap().doc, &undo_before), "saving does not copy the edit history");
s.restore_checkpoint(checkpoint).unwrap();
assert_eq!(run(&mut s, "component.links", json!({}))["links"][0]["found"], true);
let path = s.path.clone().unwrap();
for format in ["json", "container"] {
run(&mut s, "file.save", json!({"format": format}));
let disk = solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(disk.links.values().next().unwrap().path, "../../../parts/plate.solidcraft");
run(&mut s, "doc.open", json!({"path": path}));
assert!(!s.is_dirty());
assert_eq!(run(&mut s, "component.links", json!({}))["links"][0]["found"], true);
}
save_box(&part, 45.0);
assert_eq!(run(&mut s, "component.update_links", json!({}))["updated"], json!(["plate"]));
assert!((volume(&mut s) - 9125.0).abs() < 1e-6);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn recovered_relative_links_keep_their_targets_after_save_as() {
let dir = std::env::temp_dir().join(format!("solidcraft-link-recovery-{}", std::process::id()));
std::fs::create_dir_all(dir.join("parts")).unwrap();
std::fs::create_dir_all(dir.join("asm/sub")).unwrap();
let part = dir.join("parts/plate.solidcraft");
save_box(&part, 30.0);
let mut s = Session::default();
run(&mut s, "component.insert_design", json!({"path": part}));
run(&mut s, "file.save", json!({"path": dir.join("asm/case.solidcraft")}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [60, 0, 0]}));
let recovery = dir.join("recovery");
let mut autosaver = crate::recovery::Autosaver::new(&recovery, std::time::Duration::ZERO).unwrap();
assert!(autosaver.save(&s).unwrap());
drop(autosaver);
let entry = crate::recovery::orphans(&recovery).pop().unwrap();
// Older autosaves hold paths relative to the design, not the recovery folder.
std::fs::write(recovery.join(format!("{}.solidcraft", entry.id)), solidcraft_io::write_design(&s.design())).unwrap();
let mut recovered = Session::default();
run(&mut recovered, "doc.recover", json!({"dir": recovery, "id": entry.id}));
assert_eq!(run(&mut recovered, "component.links", json!({}))["links"][0]["found"], true);
run(&mut recovered, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [80, 0, 0]}));
run(&mut recovered, "file.save_as", json!({"path": dir.join("asm/sub/case.solidcraft")}));
run(&mut recovered, "edit.undo", json!({}));
assert_eq!(run(&mut recovered, "component.links", json!({}))["links"][0]["found"], true);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_saved_assembly_reopens_with_its_link_updates_it_and_breaks_it() {
let dir = std::env::temp_dir().join(format!("solidcraft-link-reopen-{}", std::process::id()));
@@ -649,4 +786,112 @@ mod tests {
assert!(update["warnings"].to_string().contains("joint PinInHole refers to faces of Plate by their place"), "{update}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A design someone sends may name a linked file on another computer (`\\host\share\…`) or a
/// device. On Windows, reading it or only asking whether it exists connects to that computer
/// and may send it the user's sign-in. Update Links and List Links judge the stored path from
/// its text and touch nothing: List Links reports it unknown with a reason, Update Links
/// refuses with the server's name, and nothing in the design changes.
#[test]
fn links_to_network_locations_or_devices_touch_nothing_until_allowed() {
let dir = std::env::temp_dir().join(format!("solidcraft-link-network-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("parts")).unwrap();
let part = dir.join("parts").join("plate.solidcraft");
save_box(&part, 30.0);
let case = dir.join("case.solidcraft");
let mut a = Session::default();
let comp = run(&mut a, "component.insert_design", json!({"path": part}))["component"].as_u64().unwrap();
run(&mut a, "file.save", json!({"path": case}));
let feature = a.doc.links[&comp].feature;
// On Unix these names are plain files beside the design, so a check that was skipped would
// find them: the refusal below comes from the text alone.
let crafted = [r"\\evil.example\share\plate.solidcraft", "//evil.example/share/plate.solidcraft", r"\\?\UNC\evil.example\share\p.solidcraft"];
if cfg!(unix) {
std::fs::copy(&part, dir.join(crafted[0])).unwrap();
}
let devices = [r"\\.\pipe\plate.solidcraft", r"\\.\COM1", "parts/NUL.solidcraft", "parts/com1.solidcraft", r"parts\CON.part.solidcraft"];
std::fs::copy(&part, dir.join("parts").join("NUL.solidcraft")).unwrap();
let before = volume(&mut a);
for link in crafted.iter().chain(&devices) {
a.doc_mut().links.insert(comp, DesignLink { path: link.to_string(), feature });
let calls = solidcraft_io::vfs::file_calls();
let listed = run(&mut a, "component.links", json!({}));
let entry = &listed["links"][0];
assert!(entry["found"].is_null() && entry["reason"].is_string(), "{link}: {listed}");
let err = a.execute("component.update_links", &json!({})).unwrap_err().to_string();
assert_eq!(solidcraft_io::vfs::file_calls(), calls, "{link}: a file call was made");
if devices.contains(link) {
assert!(entry["host"].is_null() && err.contains("names a device"), "{link}: {listed} {err}");
} else {
assert_eq!(entry["host"], "evil.example", "{link}: {listed}");
assert!(err.contains(r"network location \\evil.example"), "{link}: {err}");
}
assert_eq!(volume(&mut a), before, "{link}: the design changed");
}
// Allowed (Allow once, a preference, …), the same link is read as any other.
#[cfg(unix)]
{
a.doc_mut().links.insert(comp, DesignLink { path: crafted[0].to_string(), feature });
solidcraft_io::vfs::allowing_hosts(&["EVIL.example".to_string()], || {
let listed = run(&mut a, "component.links", json!({}));
assert_eq!(listed["links"][0]["found"], true, "{listed}");
assert_eq!(run(&mut a, "component.update_links", json!({}))["unchanged"], json!(["plate"]));
});
assert!(run(&mut a, "component.links", json!({}))["links"][0]["found"].is_null(), "allowed for that action only");
}
// A local link, relative or absolute, is found and read as before.
for local in ["parts/plate.solidcraft".to_string(), part.to_string_lossy().to_string()] {
a.doc_mut().links.insert(comp, DesignLink { path: local.clone(), feature });
assert_eq!(run(&mut a, "component.links", json!({}))["links"][0]["found"], true, "{local}");
assert_eq!(run(&mut a, "component.update_links", json!({}))["unchanged"], json!(["plate"]), "{local}");
}
// Relink to a network path (programmatic: no prompt) is refused before the disk is asked.
let calls = solidcraft_io::vfs::file_calls();
let err = a.execute("component.relink", &json!({"component": comp, "path": r"\\evil.example\share\x.solidcraft"})).unwrap_err().to_string();
assert!(err.contains("network location"), "{err}");
assert_eq!(solidcraft_io::vfs::file_calls(), calls);
let _ = std::fs::remove_dir_all(&dir);
}
/// Every place that reaches a file through a link path goes through the design-path rule
/// (`vfs::check_design_path` / `read_design_path` / `design_path_is_file` / `link_refusal`):
/// no plain file call on a link's path in the link commands, and the font file a design's text
/// names is checked before it is read.
#[test]
fn every_link_path_goes_through_the_design_path_rule() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let code = |file: &str| {
let text = std::fs::read_to_string(root.join(file)).unwrap();
// The code before its test module.
text.split("\n#[cfg(test)]\n#[allow(clippy::unwrap_used").next().unwrap_or("").to_string()
};
for file in ["src/cmd/component_link.rs", "src/cmd/component_extract.rs"] {
let text = code(file);
for raw in [
"vfs::read(",
"vfs::read_to_string(",
"vfs::is_file(",
"vfs::exists(",
"vfs::len(",
"std::fs::read",
"std::fs::metadata",
".is_file()",
".exists()",
".metadata()",
] {
assert!(!text.contains(raw), "{file}: `{raw}` on a link's path must go through vfs::check_design_path");
}
// Relink canonicalises the path it is given, after the rule.
if let Some(at) = text.find("canonicalize") {
let rule = text[..at].rfind("link_refused(s.path.as_deref(), given)").unwrap_or(usize::MAX);
assert!(rule < at, "{file}: canonicalize before check_design_path");
}
}
let fonts = std::fs::read_to_string(root.join("../sketch/src/fonts.rs")).unwrap();
let (check, read) = (fonts.find("FILE_CHECK.get()").unwrap(), fonts.find("read_font(&path)").unwrap());
assert!(check < read, "a font file named by a design is checked before it is read");
let io = std::fs::read_to_string(root.join("../io/src/vfs.rs")).unwrap();
assert!(io.contains("set_font_file_check(|p| check_design_path("), "the font check is the design-path rule");
}
}
+13
View File
@@ -9,6 +9,7 @@
use serde_json::Value;
use solidcraft_doc::construct::{AxisDef, GeoRef, PointDef};
use solidcraft_doc::{FeatureKind, PlaneRef};
use solidcraft_geom::Vec3;
use super::CommandSpec;
use super::features::{add_feature, check_expr, plane_param, sketch_id};
@@ -88,6 +89,18 @@ pub static COMMANDS: &[CommandSpec] = &[
),
];
/// An axis on an edge or round face of the model (`{edge: [x,y,z]}` or `{face: [x,y,z]}`, the
/// point on it), with the line it gives now (`solidcraft_doc::construct::model_axis`); `None`
/// for any other form of `axis`.
pub(super) fn model_axis_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<Option<(GeoRef, Vec3, Vec3)>> {
// Also as saved (`{type: edge|face, at, name?}`), as an edit dialog gives back one it kept.
let saved = |v: &Value| matches!(v.get("type").and_then(Value::as_str), Some("edge" | "face"));
let Some(v) = v.filter(|v| v.get("edge").is_some() || v.get("face").is_some() || saved(v)) else { return Ok(None) };
let g = geo(s, Some(v), cmd, "axis")?;
let (o, d) = solidcraft_doc::construct::model_axis(&s.world_state(), &g).map_err(|e| bad(cmd, format!("`axis`: {e}")))?;
Ok(Some((g, o, d)))
}
/// A reference from its JSON form.
pub(super) fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<GeoRef> {
let v = v.ok_or_else(|| bad(cmd, format!("`{what}` is required: {REF}")))?;
+2 -2
View File
@@ -632,7 +632,7 @@ fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut();
for (id, made) in &keep {
if let Some(f) = doc.feature_mut(*id) {
f.body_names.clone_from(made);
f.pin_body_names(made);
}
}
for id in &ids {
@@ -1070,7 +1070,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
}
// Profiles picked by index or point are kept by their curves, as when the feature was made.
if nf.kind.clone().profile_sels_mut() != f.kind.clone().profile_sels_mut() {
super::features::stable_profiles(s, &mut nf.kind)?;
super::features::stable_profiles(s, &mut nf.kind, false)?;
}
// New face points: their faces' names now.
if nf.kind.face_points() != f.kind.face_points() {
+47
View File
@@ -822,6 +822,53 @@ fn suppressing_a_feature_keeps_later_counted_body_names() {
assert!(works(&s));
}
/// Counted body names are pinned when the body is made, as if the user had renamed it: deleting,
/// reordering or rolling back another feature leaves the other bodies' names alone, and a number
/// a deleted feature held is not handed to the next body.
#[test]
fn counted_body_names_stay_when_the_timeline_changes() {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20), ("C", 40)] {
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "name": name}));
}
run(&mut s, "solid.move", json!({"bodies": ["Body2"], "translate": [0, 30, 0], "name": "Move"}));
let bodies = |s: &Session| -> Vec<(String, String)> {
let st = s.model.state();
st.bodies.iter().map(|b| (b.name.clone(), s.doc.feature(b.feature).map(|f| f.name.clone()).unwrap_or_default())).collect()
};
let named = |pairs: &[(&str, &str)]| pairs.iter().map(|(b, f)| (b.to_string(), f.to_string())).collect::<Vec<_>>();
let works = |s: &Session| s.doc.features.iter().all(|f| s.model.result(f.id).is_some_and(|r| r.error.is_none()));
// The names are stored on the features as soon as the bodies exist.
assert_eq!(s.doc.find_feature("B").map(|f| f.body_names.clone()), Some(vec!["Body2".to_string()]));
assert!(["Body1", "Body2", "Body3"].iter().all(|n| s.doc.reserved_body_names.contains(*n)));
// Reordering A behind B: every body keeps its number, wherever it comes in the list.
run(&mut s, "timeline.reorder", json!({"feature": "A", "position": 2, "active": true}));
let mut reordered = bodies(&s);
reordered.sort();
assert_eq!(reordered, named(&[("Body1", "A"), ("Body2", "B"), ("Body3", "C")]));
assert!(works(&s));
run(&mut s, "edit.undo", json!({}));
// Rolled back to before B and forward again.
run(&mut s, "timeline.roll_to", json!({"position": 1}));
run(&mut s, "timeline.roll_to", json!({"position": "end"}));
assert_eq!(bodies(&s), named(&[("Body1", "A"), ("Body2", "B"), ("Body3", "C")]));
// Deleting A: B and C keep their names, so the Move still finds Body2.
run(&mut s, "timeline.delete", json!({"feature": "A"}));
assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C")]));
assert!(works(&s), "the Move still finds Body2");
// The number A held is not handed out again.
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0], "name": "D"}));
assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C"), ("Body4", "D")]));
// Undo takes the box away, and the number back with it.
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0], "name": "E"}));
assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C"), ("Body4", "E")]));
}
/// The value at a dotted path of a definition.
fn dotted<'a>(def: &'a Value, path: &str) -> &'a Value {
path.split('.').fold(def, |v, k| &v[k])
+290 -55
View File
@@ -25,11 +25,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.key("E")
.contract(&EXTRUDE_CONTRACT)
.output(output_schema_for::<FeatureResult>)
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
.params("distance: expr (or through_all: true); taper?: angle expr (positive: outward, so the far end is larger along the extrusion direction; negative: inward, so the far end is smaller); sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile; a pick on a drawn profile that reference geometry, such as the projected outline of the face the sketch is on, cuts into regions takes the whole drawn profile); exact_regions?: bool (each index or point pick is exactly the region it names, as the dialogs send); default: every drawn profile, except that a loop inside another is its hole and a loop inside that hole is material again; face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?: expr (Two Sides: a second distance the other way from the first side, which may be through_all or to; with direction: negative the first side goes against the normal and distance2 along it); start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | construction axis name (followed when edited) | {origin, dir}; 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?, targets?, name?, body_name?"),
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world) | construction axis name (followed when edited) | {origin, dir} | {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?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
@@ -58,7 +58,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | construction axis name (followed when edited) | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | construction axis name (followed when edited) | {origin, dir} | {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); count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_path")
@@ -68,7 +68,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; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: metric major minus pitch or tabulated UNC/UNF, G or Rp cut-tap drill) | 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?; cs_diameter?, cs_angle?; thread?: \"M6\" or \"1/4-20 UNC\" (cosmetic); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); 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; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: metric major minus pitch or tabulated UNC/UNF, G or Rp cut-tap drill) | 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?; cs_diameter?, cs_angle?; thread?: \"M6\" or \"1/4-20 UNC\" (cosmetic); handedness?: right|left (default right, needs thread for left); thread_depth?: positive length independent of drill depth (default: full useful cylindrical wall; must fit that wall); 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", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
@@ -92,7 +92,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); handedness?: right|left (default right); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
@@ -108,7 +108,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane_angle")
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir}; angle: expr; name?"),
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; axis: X|Y|Z|construction axis name (where it is now)|{origin, dir} | {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; name?"),
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split_face")
@@ -198,14 +198,21 @@ fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'stat
// Probe in the same coordinate frame as the face picks.
let grouped = p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first()).and_then(vec3);
let st = if grouped.is_some() && world_faces { s.world_state() } else { s.model.state() };
// The body the extrude starts on, when it is in a component inside the active one: it takes
// part as the active component's own bodies do (#5679).
let mut started_on = None;
let (base, n) = if let Some(point) = grouped {
let (mesh, index, distance, name) = super::face::face_at_state(&st, point, str_(p, "face_body")).ok().flatten()?;
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
return None;
}
let normal = st.body(&name)?.body.planar_face(index as usize)?.normal();
started_on = Some(name);
(point, normal)
} else {
if p.get("face").is_none() {
started_on = feature_sketch(s, p, "Extrude").ok().and_then(|sketch| face_body_reached(s, sketch).ok().flatten());
}
extrude_base(s, p)?
};
// The extrude starts at its From Object or start offset, not at the profile: probe from
@@ -232,17 +239,27 @@ fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'stat
// A component with no body of its own works on those of the components inside it (#3223).
let parts = string_list(p, "participants");
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
let bodies: Vec<_> = if !parts.is_empty() {
let mut bodies: Vec<_> = if !parts.is_empty() {
st.bodies.iter().filter(|b| parts.contains(&b.name)).collect()
} else if implicit_all {
st.bodies.iter().collect()
} else {
active_scope(s, &st)
};
if parts.is_empty()
&& let Some(body) = started_on.as_deref().and_then(|name| st.body(name))
&& !bodies.iter().any(|b| b.name == body.name)
&& s.doc.component_within(s.doc.model_body_component(body), s.active_component)
{
bodies.push(body);
}
if bodies.is_empty() {
return Some("new");
}
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
// A profile's points are in the active component's frame; a body placed differently (in a
// moved or turned component) is probed where it is shown. Face picks are in `st`'s frame.
let frames: Vec<Option<solidcraft_doc::Mat>> = bodies.iter().map(|b| if grouped.is_some() { None } else { into_body_frame(s, b) }).collect();
let inside = |q: Vec3| bodies.iter().zip(&frames).any(|(b, m)| b.mesh().contains(m.as_ref().map_or(q, |m| solidcraft_doc::apply_point(m, q))));
// A step into whatever is right in front of the profile, short of the far end.
let near = |d: f64| d.signum() * (d.abs() * 0.5).min(START_PROBE_MM);
let into = |d: f64| inside(base + n * near(d)) || inside(base + n * (d * 0.5));
@@ -309,7 +326,11 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
/// whose curves would not find that same profile again (two profiles bounded by the same
/// curves) stays as given. Curve picks are checked: one that names no profile, or several, is
/// refused now rather than failing later.
pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
///
/// `exact` keeps each index or point pick to the region it names. Otherwise a pick on a drawn
/// profile that reference geometry cuts into regions takes the whole drawn profile
/// ([`drawn_profile_regions`], #5302).
pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind, exact: bool) -> Result<()> {
use solidcraft_doc::sketch::profile_at_point;
let st = s.model.state();
for (sketch, sel) in kind.profile_sels_mut() {
@@ -373,7 +394,8 @@ pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()>
}
ProfileSel::All => continue,
};
let captured = solidcraft_doc::capture_profile_selection(ss, &drawn_profile_regions(ss, &indices))?;
let picked = if exact { indices } else { drawn_profile_regions(ss, &indices) };
let captured = solidcraft_doc::capture_profile_selection(ss, &picked)?;
*sel = match captured {
ProfileSel::OffsetBoundaries { associations }
if associations.iter().any(|id| {
@@ -480,6 +502,13 @@ fn drawn_profile_regions(ss: &solidcraft_doc::SolvedSketch, indices: &[usize]) -
out
}
/// `exact_regions: true`: each index or point pick is the region it names, never grown to the
/// whole drawn profile. The dialogs send it: they highlight exactly the regions picked, and OK
/// must build those (#6724).
pub(super) fn exact_regions(p: &Value) -> bool {
bool_(p, "exact_regions").unwrap_or(false)
}
pub(crate) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
match v {
@@ -588,7 +617,7 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
}
// Profiles picked by index are kept by the curves around them: a recompute can renumber a
// sketch's profiles (a face sketch's projected outline rebuilt), never the curves.
stable_profiles(s, &mut kind)?;
stable_profiles(s, &mut kind, exact_regions(p))?;
// Blends remember where their edges sat, to find them again after upstream edits.
let (refs, names) = match &kind {
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
@@ -780,14 +809,20 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
_ => operation(p, cmd)?,
};
let mut targets = string_list(p, "targets");
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
let mut p = std::borrow::Cow::Borrowed(p);
if operation == Operation::Join && targets.is_empty() && string_list(&p, "participants").is_empty() {
// A face's outward extension joins that body, and so does a profile sketched on a body's
// face. Assembly neighbours can touch only an edge of the tool: trying to join them
// first fails, or absorbs unrelated parts (#226). A profile drawn past the face can lie
// on a neighbour's face too: a rib on a bracket's side welded in the motor the bracket
// holds, a join that ran for minutes and failed (#1349).
// Store the resolved target so recomputes and reopening keep the same scope.
targets.extend(face_body.or_else(|| sketch_face_body(s, sketch)));
targets.extend(match face_body {
Some(body) => Some(body),
None => face_body_reached(s, sketch)?,
});
} else if let Some(parts) = cut_scope(s, &p, operation, sketch, face_body)? {
p.to_mut()["participants"] = json!(parts);
}
let kind = FeatureKind::Extrude {
sketch: SketchId(sketch),
@@ -795,14 +830,39 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => f,
None if thin.is_some() => ProfileSel::All,
None if p.get("profiles").is_none() => default_extrude_profiles(s, sketch),
None => profiles(p, cmd)?,
None => profiles(&p, cmd)?,
},
extent,
operation,
targets: targets.into_iter().map(BodyName).collect(),
thin,
};
add_feature(s, p, kind)
add_feature(s, &p, kind)
}
/// The bodies a cut or intersect from a profile on a body's face takes part in when it names
/// none and that body is in a component inside the active one (#5679): the active component's
/// own bodies, as by default, and that body. `None` when the default already reaches it: it is
/// one of the active component's, or a component with no body of its own cuts every body.
fn cut_scope(s: &Session, p: &Value, operation: Operation, sketch: u64, face_body: Option<String>) -> Result<Option<Vec<String>>> {
if !matches!(operation, Operation::Cut | Operation::Intersect)
|| !string_list(p, "targets").is_empty()
|| !string_list(p, "participants").is_empty()
{
return Ok(None);
}
let Some(body) = face_body_reached(s, sketch)?.or(face_body) else { return Ok(None) };
let st = s.model.state();
if s.cut_takes_every_body(&st) {
return Ok(None);
}
let mut parts: Vec<String> = active_scope(s, &st).into_iter().map(|b| b.name.clone()).collect();
let within = st.body(&body).is_some_and(|b| s.doc.component_within(s.doc.model_body_component(b), s.active_component));
if parts.contains(&body) || !within {
return Ok(None);
}
parts.push(body);
Ok(Some(parts))
}
/// The profiles an extrude takes when `profiles` is omitted: even-odd nesting over what was drawn,
@@ -846,6 +906,68 @@ fn default_extrude_profiles(s: &Session, sketch: u64) -> ProfileSel {
}
}
/// The matrix taking a point of the active component's frame into the frame `b`'s geometry is
/// kept in, as the evaluator moves a tool into it; `None` when the two are the same.
fn into_body_frame(s: &Session, b: &solidcraft_doc::ModelBody) -> Option<solidcraft_doc::Mat> {
let frame = s.doc.component_transform(s.doc.model_body_component(b));
let world = s.doc.model_body_offset(b).map_or(frame, |offset| solidcraft_doc::mat_mul(&frame, offset));
let active = s.doc.component_transform(s.active_component);
if world == active {
return None;
}
solidcraft_doc::mat_inverse(&world).map(|into| solidcraft_doc::mat_mul(&into, &active))
}
/// The body whose face `sketch` is attached to, when a join or cut made in the active component
/// can change it: one of the active component's own ([`active_scope`]), or one in a component
/// inside it, moved or turned, which is changed in its own component, so every placement of that
/// component shows the change (#5679). `Ok(None)` when the sketch is not on a face, or the body is
/// outside the active component. Refused where the change can't be made in the body's component:
/// its bodies come from a linked design file, or the face was picked on a further placement of a
/// component placed more than once (the change is drawn at the first).
fn face_body_reached(s: &Session, sketch: u64) -> Result<Option<String>> {
let Some(FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, .. }) = s.doc.feature(sketch).map(|f| &f.kind)
else {
return Ok(None);
};
let st = s.model.state();
if let Some(own) = active_scope(s, &st).into_iter().find(|b| b.name == anchor.target.body) {
return Ok(Some(own.name.clone()));
}
let Some(body) = st.body(&anchor.target.body) else { return Ok(None) };
let component = s.doc.model_body_component(body);
if !s.doc.component_within(component, s.active_component) {
return Ok(None);
}
let name = |c: u64| s.doc.components.iter().find(|x| x.id == c).map_or_else(|| format!("component {c}"), |x| x.name.clone());
let mut c = component;
for _ in 0..1000 {
if c == s.active_component || c == 0 {
break;
}
if s.doc.links.contains_key(&c) {
return Err(EngineError::Other(format!(
"`{}` is in {}, which is linked to a design file: open that design to change it, or break the link first",
body.name,
name(c)
)));
}
match s.doc.components.iter().find(|x| x.id == c) {
Some(x) => c = x.parent,
None => break,
}
}
if !anchor.path.is_empty() && anchor.path != s.doc.first_path(component) {
return Err(EngineError::Other(format!(
"`{}` is in {}, which is placed more than once, and the sketch is on another placement than the first: activate that placement of {} to cut or join it there",
body.name,
name(component),
name(component)
)));
}
Ok(Some(body.name.clone()))
}
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
/// it is one the active component works on ([`active_scope`]).
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
@@ -857,13 +979,20 @@ fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
}
/// The bodies a Join named none for: the body whose face a profile sketch is on (the first
/// sketch that is on one), as for an extrude. Explicit `targets` or `participants` keep theirs.
fn face_join_targets(s: &Session, p: &Value, operation: Operation, sketches: &[u64]) -> Vec<BodyName> {
/// sketch that is on one), as for an extrude, also in a component inside the active one, moved
/// or turned (#5679; refused as [`face_body_reached`] says). Explicit `targets` or
/// `participants` keep theirs.
fn face_join_targets(s: &Session, p: &Value, operation: Operation, sketches: &[u64]) -> Result<Vec<BodyName>> {
let mut targets = string_list(p, "targets");
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
targets.extend(sketches.iter().find_map(|sk| sketch_face_body(s, *sk)));
for sketch in sketches {
if let Some(body) = face_body_reached(s, *sketch)? {
targets.push(body);
break;
}
}
}
targets.into_iter().map(BodyName).collect()
Ok(targets.into_iter().map(BodyName).collect())
}
/// The bodies a feature of the active component works on when it names none: the component's
@@ -1031,25 +1160,36 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?,
};
let operation = operation(p, cmd)?;
let targets = face_join_targets(s, p, operation, &[sketch])?;
// From a profile on a body's face in a component inside the active one, a cut changes that
// body too, as an extrude's does (#5679).
let mut p = std::borrow::Cow::Borrowed(p);
if let Some(parts) = cut_scope(s, &p, operation, sketch, None)? {
p.to_mut()["participants"] = json!(parts);
}
let kind = FeatureKind::Revolve {
sketch: SketchId(sketch),
profiles,
axis,
angle,
operation,
targets: face_join_targets(s, p, operation, &[sketch]),
targets,
angle2,
symmetric,
convention,
to: to.map(FacePick),
};
add_feature(s, p, kind)
add_feature(s, &p, kind)
}
/// A revolve axis of `sketch` from its parameter: a sketch line id, the sketch's x or y axis, a
/// world axis, a construction axis by name (followed when it is edited) or {origin, dir}. A line
/// of the sketch wins over a construction axis of the same name.
/// world axis, a construction axis by name (followed when it is edited), {origin, dir}, or an edge
/// or round face of the model ({edge|face: [x,y,z]}), which the revolve follows as the model
/// changes. A line of the sketch wins over a construction axis of the same name.
pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Result<AxisRef> {
if let Some((geo, ..)) = super::construct::model_axis_param(s, p.get("axis"), cmd)? {
return Ok(AxisRef::Model { geo });
}
if let Some(o) = p.get("axis").filter(|v| v.is_object()) {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
@@ -1060,7 +1200,9 @@ pub(super) fn axis_param(s: &Session, sketch: u64, p: &Value, cmd: &str) -> Resu
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
Some(name) if !is_sketch_line(s, sketch, name) && is_construction_axis(s, name) => Ok(AxisRef::Construction { name: name.into() }),
Some(id) => Ok(AxisRef::SketchLine { curve: id.into() }),
None => Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis or {origin, dir}")),
None => {
Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}"))
}
}
}
@@ -1208,7 +1350,8 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
// of the body the feature names, or of any body: a pick far from every edge would round
// whichever edge is nearest (#783). Each body's edges are worked out once.
let named = str_(p, "body").filter(|n| st.body(n).is_some());
let mut candidates: Option<Vec<(f64, Vec<solidcraft_kernel::EdgeInfo>)>> = None;
// Per body: its size, its edges' polylines and which of them are seams.
let mut candidates: Option<Vec<(f64, Vec<Vec<Vec3>>, Vec<bool>)>> = None;
let mut out = Vec::new();
for e in a {
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
@@ -1229,21 +1372,50 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
.filter_map(|b| {
let size = b.body.size();
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
let tol = (size * 2e-3).max(1e-3);
// The display mesh flags seams (a smooth surface meeting itself, as down the side
// of a cylinder), which nobody means by a pick where they end.
match b.body.display_mesh(tol) {
Ok(m) => Some((size, m.edges, m.seams)),
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
}
})
.collect()
});
// A point where several edges meet (a corner) is ambiguous: the kernel would round one of
// them, picked silently (#5173). Edges that carry straight on through the point (the two
// halves of a circle) are one edge to whoever picked it, and seams don't count. Directions
// come from the polylines' end chords, which lean off a curve's tangent: within about 45°
// of opposite counts as carrying on (edges at a real corner are far from opposite).
let mut nearest = f64::INFINITY;
let on_edge = bodies.iter().any(|(size, edges)| {
let d = edges
.iter()
.flat_map(|e| e.points.windows(2))
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
.fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
d <= size * 0.05 + 1e-3
});
if !on_edge && !bodies.is_empty() {
let mut near = Vec::new();
for (size, edges, seams) in bodies.iter() {
for (i, points) in edges.iter().enumerate() {
let d = points.windows(2).filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?))).fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
if d <= size * 0.05 + 1e-3 {
near.push((d, seams.get(i).copied().unwrap_or(false), leaving(points, pt)));
}
}
}
let matches = near.len();
let mut meeting: Vec<Option<Vec3>> = near.iter().filter(|(d, seam, _)| !seam && *d <= nearest + 1e-3).map(|(_, _, dir)| *dir).collect();
let mut chains = 0usize;
while let Some(dir) = meeting.pop() {
chains += 1;
if let Some(dir) = dir
&& let Some(j) = meeting.iter().position(|o| o.is_some_and(|other| other.dot(dir) < -0.7))
{
meeting.swap_remove(j);
}
}
if chains > 1 {
return Err(bad(
cmd,
format!("[{}, {}, {}] is where {chains} edges meet: give a point along one edge, such as its midpoint", pt.x, pt.y, pt.z),
));
}
if matches == 0 && !bodies.is_empty() {
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
}
@@ -1252,6 +1424,19 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
Ok(out)
}
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
/// the edge runs on through `pt`.
fn leaving(points: &[Vec3], pt: Vec3) -> Option<Vec3> {
let (first, last) = (points.first()?, points.last()?);
if first.dist(pt) <= 1e-3 {
(*points.get(1)? - *first).normalized()
} else if last.dist(pt) <= 1e-3 {
(*points.get(points.len().checked_sub(2)?)? - *last).normalized()
} else {
None
}
}
/// An edge reference as given, before it is looked up: see [`edge_points`].
pub(crate) enum EdgeRefForm<'a> {
Point(Vec3),
@@ -1556,17 +1741,41 @@ fn move_body_feature(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(a) = &angle {
check_expr(s, a, Kind::Angle, cmd, "angle")?;
}
add_feature(
let copy = bool_(p, "copy").unwrap_or(false);
let reply = add_feature(
s,
p,
FeatureKind::Move {
bodies: bodies.into_iter().map(BodyName).collect(),
bodies: bodies.iter().cloned().map(BodyName).collect(),
translate: t,
rotate_axis: p.get("axis").and_then(vec3).map(Dir),
angle,
copy: bool_(p, "copy").unwrap_or(false),
copy,
},
)
)?;
if copy {
copy_keeps_appearance(s, &reply, &bodies, cmd)?;
}
Ok(reply)
}
/// A copy keeps the look and the material of the body it came from (#6423): the copy is the same
/// part in another place, so it looks the same.
fn copy_keeps_appearance(s: &mut Session, reply: &Value, sources: &[String], cmd: &str) -> Result<()> {
let id = reply.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "the copy has no feature id"))?;
let copies: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
if copies.len() != sources.len() {
return Err(bad(cmd, "the copy did not make one body per body it copied"));
}
for (source, copy) in sources.iter().zip(copies.iter()) {
if let Some(look) = s.doc.appearances.bodies.get(source).cloned() {
s.doc_mut().appearances.bodies.insert(copy.clone(), look);
}
if let Some(material) = s.doc.materials.get(source).cloned() {
s.doc_mut().materials.insert(copy.clone(), material);
}
}
Ok(())
}
fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
@@ -1582,9 +1791,19 @@ fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
Ok(names)
}
/// An axis line from its parameter, with what it follows: an edge or round face of the model
/// ({edge|face: [x,y,z]}) or a construction axis by name ([`named_axis_line`]).
fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<solidcraft_doc::construct::GeoRef>, Option<FeatureName>)> {
if let Some((geo, o, d)) = super::construct::model_axis_param(s, p.get("axis"), cmd)? {
return Ok((o, d, Some(geo), None));
}
let (o, d, name) = named_axis_line(s, p, cmd)?;
Ok((o, d, None, name))
}
/// An axis line from `axis`: X, Y, Z, a construction axis by name or {origin, dir}. A construction
/// axis comes back where it is now, with its name.
fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
fn named_axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<FeatureName>)> {
match p.get("axis") {
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
"X" => Ok((Vec3::ZERO, Vec3::X, None)),
@@ -1597,7 +1816,7 @@ fn axis_line(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3, Option<Fe
.ok_or_else(|| bad(cmd, format!("construction axis `{a}` has no geometry: fix its definition first")))?;
Ok((origin, dir, Some(a.as_str().into())))
}
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis or {origin, dir}")),
_ => Err(bad(cmd, "axis must be X, Y, Z, a construction axis, {origin, dir} or {edge|face: [x,y,z]}")),
},
Some(o @ Value::Object(_)) => {
let origin = match o.get("origin") {
@@ -1691,7 +1910,7 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.circular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let (origin, axis, axis_feature) = axis_line(s, p, cmd)?;
let (origin, axis, axis_ref, axis_feature) = axis_line(s, p, cmd)?;
let count = req_expr(cmd, p, "count")?;
check_expr(s, &count, Kind::Unitless, cmd, "count")?;
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
@@ -1703,6 +1922,7 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
angle,
alignment: super::alignment_param(p, "alignment", cmd)?,
reversed: super::reversed_param(p, "reversed", cmd)?,
axis_ref,
axis_feature,
};
finish_pattern(s, p, features, pattern, bodies)
@@ -1895,24 +2115,22 @@ fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.at_angle";
// The plane keeps the axis line as it is now.
let (axis_origin, axis_dir, _) = axis_line(s, p, cmd)?;
// The plane follows an edge or face it is on; a construction axis is kept as it is now.
let (axis_origin, axis_dir, line, _) = axis_line(s, p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let line = line.map(Box::new);
// A planar body face: attached exactly, as a sketch on that face is, so the plane follows it.
if let Some(face) = p.get("base").filter(|b| b.get("face").is_some()) {
let base = super::sketch::face::capture(s, face, cmd)?;
super::sketch::face::capture_positions(s, &base)?;
let kind = FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle } };
let plane = solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle, line };
// The capture is already in the authoring frame.
return add_feature_in(s, p, kind, false);
return add_feature_in(s, p, FeatureKind::ConstructionPlane { plane }, false);
}
let base = plane_param(s, p.get("base"), cmd)?;
add_feature(
s,
p,
FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle } },
)
let plane = solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle, line };
add_feature(s, p, FeatureKind::ConstructionPlane { plane })
}
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
@@ -2121,6 +2339,10 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
_ => None,
};
let thread = str_(p, "thread").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
let handedness = thread_handedness(p, cmd)?;
if thread.is_none() && !handedness.is_right() {
return Err(bad(cmd, "`handedness` needs `thread` (a tapped hole)"));
}
if let Some(t) = &thread
&& solidcraft_doc::parse_tapped_thread(t).is_none()
{
@@ -2293,6 +2515,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
points: points.map(|x| x.0),
thread,
thread_depth,
handedness,
to: to.map(FacePick),
references,
},
@@ -2434,7 +2657,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
let (rails, rail_fit) = sweep_rails(s, p, cmd, path_sketch)?;
let operation = operation(p, cmd)?;
let targets = face_join_targets(s, p, operation, &[sketch]);
let targets = face_join_targets(s, p, operation, &[sketch])?;
add_feature(
s,
p,
@@ -2572,10 +2795,17 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let (rails, rail_fit) = guide_rails(s, p, cmd, "loft", None)?;
let operation = operation(p, cmd)?;
let sketches: Vec<u64> = sections.iter().map(|sec| sec.sketch.0).collect();
let targets = face_join_targets(s, p, operation, &sketches);
let targets = face_join_targets(s, p, operation, &sketches)?;
add_feature(s, p, FeatureKind::Loft { sections, operation, targets, rails, rail_fit })
}
fn thread_handedness(p: &Value, cmd: &str) -> Result<solidcraft_doc::ThreadHandedness> {
match p.get("handedness") {
None => Ok(solidcraft_doc::ThreadHandedness::Right),
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`handedness` must be `right` or `left`")),
}
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.thread";
let face = p
@@ -2598,7 +2828,8 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
{
return Err(bad(cmd, msg));
}
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled })?;
let handedness = thread_handedness(p, cmd)?;
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled, handedness })?;
let st = s.model.state();
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
@@ -2687,6 +2918,10 @@ fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
#[path = "patterns_tests.rs"]
mod patterns_tests;
#[cfg(test)]
#[path = "model_axis_tests.rs"]
mod model_axis_tests;
#[cfg(test)]
#[path = "prism_local_tests.rs"]
mod prism_local_tests;
+193 -1
View File
@@ -52,7 +52,22 @@ fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() {
}
run(&mut s, "solid.box", json!({"corner":[100,0,0],"length":10,"width":10,"height":10,"body_name":"Other"}));
let mut params = json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2});
assert_eq!(crate::auto_operation(&s, &params), Some("join"), "an existing active-component body keeps its original participant scope");
// The picked part takes part beside the active component's own body (#5679): going in cuts
// it, and the top level's own body is left alone.
assert_eq!(crate::auto_operation(&s, &params), Some("cut"), "the picked body takes part beside the active component's own");
for operation in ["auto", "cut"] {
let mut t = s.scratch();
let mut p = params.clone();
p["operation"] = json!(operation);
run(&mut t, "solid.extrude", p);
let volume = |name: &str| solidcraft_kernel::measure(&t.model.state().body(name).unwrap().body).unwrap().volume;
assert!(
(volume("Stock") - 800.).abs() < 1e-6 && (volume("Other") - 1000.).abs() < 1e-6,
"{operation}: {} {}",
volume("Stock"),
volume("Other")
);
}
params["participants"] = json!(["Stock"]);
assert_eq!(crate::auto_operation(&s, &params), Some("cut"));
params["participants"] = json!(["Other"]);
@@ -160,6 +175,183 @@ fn every_inward_extrude_into_a_part_in_another_component_cuts_it() {
assert!(wrong.is_empty(), "{} wrong:\n{}", wrong.len(), wrong.join("\n"));
}
/// #5679's part: a 10 mm cube `Stock` in component Part, its placement moved to (30, 5, 2) and
/// turned 35 degrees about (1, 2, 0), with the top level active; with `base`, the top level has a
/// body of its own, a 20 x 20 x 5 `Base` at (-40, -40, 0). Returns Part's placement.
fn turned_part(base: bool) -> (Session, solidcraft_doc::Mat) {
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Stock"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": part["occurrence"], "translate": [30, 5, 2], "axis": [1, 2, 0], "angle": "35 deg"}));
run(&mut s, "component.capture_position", json!({}));
if base {
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 5, "corner": [-40, -40, 0], "operation": "new", "body_name": "Base"}));
}
let placed = s.doc.component_transform(part["component"].as_u64().unwrap());
(s, placed)
}
/// A sketch on the face at world point `at`, with `draw` given the point's place in the sketch.
fn sketch_on_face(s: &mut Session, at: solidcraft_geom::Vec3, draw: impl FnOnce(&mut Session, solidcraft_geom::Vec2, solidcraft_geom::Plane)) {
run(s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
let plane = s
.doc
.features
.iter()
.rev()
.find_map(|f| match &f.kind {
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(*plane),
_ => None,
})
.unwrap();
draw(s, plane.to_local(at), plane);
run(s, "sketch.finish", json!({}));
}
fn body_volume(s: &Session, name: &str) -> f64 {
solidcraft_kernel::measure(&s.model.state().body(name).unwrap().body).unwrap().volume
}
/// #5679: with the top level active, a circle sketched on the top face of a part in a moved and
/// turned sub-component reads as a Cut going in and a Join going out, and either one, automatic
/// or chosen, changes that part's body in its own component: the hole is where the circle is
/// shown, not where the numbers would be in the part's frame. The top level's own body is left
/// alone, whether it has one or not.
#[test]
fn a_sketch_on_a_turned_sub_components_face_cuts_and_joins_it_from_the_top_level() {
let hole = std::f64::consts::PI * 4.0 * 3.0;
for base in [false, true] {
let (mut s, placed) = turned_part(base);
let top = solidcraft_doc::apply_point(&placed, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
sketch_on_face(&mut s, top, |s, c, _| {
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
});
for (distance, expected) in [(-3.0, "cut"), (3.0, "join")] {
assert_eq!(crate::auto_operation(&s, &json!({"distance": distance})), Some(expected), "base {base}, {distance}");
for operation in ["auto", expected] {
let mut t = s.scratch();
run(&mut t, "solid.extrude", json!({"distance": distance, "operation": operation}));
let case = format!("base {base}, {distance} {operation}");
let names: Vec<String> = t.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, if base { vec!["Stock", "Base"] } else { vec!["Stock"] }, "{case}");
let want = if expected == "cut" { 1000.0 - hole } else { 1000.0 + hole };
assert!((body_volume(&t, "Stock") - want).abs() < 1e-3 * hole, "{case}: {}", body_volume(&t, "Stock"));
// In the part's own frame: through the middle of its top, not beside it.
let stock = t.model.state().body("Stock").unwrap().mesh();
let at = |x: f64, y: f64, z: f64| stock.contains(solidcraft_geom::Vec3::new(x, y, z));
if expected == "cut" {
assert!(!at(5.0, 5.0, 8.0) && at(5.0, 5.0, 6.0) && at(1.0, 1.0, 9.0), "{case}");
} else {
assert!(at(5.0, 5.0, 12.0) && !at(1.0, 1.0, 12.0), "{case}");
}
if base {
assert!((body_volume(&t, "Base") - 2000.0).abs() < 1e-6, "{case}");
}
}
}
}
}
/// #5679: a revolve from the top level, from a profile on the turned part's top face, joins or
/// cuts that part. A 6 x 1 rectangle turned about its long edge, which lies on the face, is a
/// cylinder of radius 1 half above the face and half in the part: a Join adds 3π, a Cut takes it.
#[test]
fn a_revolve_from_a_turned_sub_components_face_joins_and_cuts_it_from_the_top_level() {
let half = std::f64::consts::PI * 3.0;
for (operation, want) in [("join", 1000.0 + half), ("cut", 1000.0 - half)] {
let (mut s, placed) = turned_part(true);
let top = solidcraft_doc::apply_point(&placed, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
let mut axis = json!(null);
sketch_on_face(&mut s, top, |s, c, plane| {
run(s, "sketch.rectangle.two_point", json!({"p0": [c.x - 3.0, c.y], "p1": [c.x + 3.0, c.y + 1.0]}));
axis = json!({"origin": [top.x, top.y, top.z], "dir": [plane.x.x, plane.x.y, plane.x.z]});
});
run(&mut s, "solid.revolve", json!({"axis": axis, "operation": operation}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Stock", "Base"], "{operation}");
assert_eq!(s.model.state().body("Stock").map(|b| b.body.shell_count()), Some(1), "{operation}");
assert!((body_volume(&s, "Stock") - want).abs() < 1e-3 * half, "{operation}: {}", body_volume(&s, "Stock"));
assert!((body_volume(&s, "Base") - 2000.0).abs() < 1e-6, "{operation}");
}
}
/// #5679 for Loft (and Sweep, through the same join target): with the top level active, a loft
/// from a circle on the turned part's top face to one 5 mm above joins the part, in one piece.
#[test]
fn a_loft_from_a_turned_sub_components_face_joins_it_from_the_top_level() {
let (mut s, placed) = turned_part(true);
let top = solidcraft_doc::apply_point(&placed, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
let mut centre = solidcraft_geom::Vec2::new(0.0, 0.0);
sketch_on_face(&mut s, top, |s, c, _| {
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
centre = c;
});
let low = s.doc.features.iter().rev().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
let high = run(&mut s, "sketch.create", json!({"plane": {"face": [top.x, top.y, top.z]}, "offset": 5, "project_edges": false}))["sketch"].clone();
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.loft", json!({"sections": [{"sketch": low}, {"sketch": high}], "operation": "join"}));
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names, ["Stock", "Base"]);
assert_eq!(s.model.state().body("Stock").map(|b| b.body.shell_count()), Some(1));
let added = std::f64::consts::PI * 4.0 * 5.0;
assert!((body_volume(&s, "Stock") - 1000.0 - added).abs() < 1e-3 * added, "{}", body_volume(&s, "Stock"));
assert!((body_volume(&s, "Base") - 2000.0).abs() < 1e-6);
}
/// #5679: the part changes in its component, so a second placement of it shows the hole too. A
/// sketch on that second placement's face would draw the hole where the first one is: the cut
/// is refused with the reason, and the design stays as it was.
#[test]
fn a_cut_from_the_top_level_shows_in_every_placement_and_a_later_placement_is_refused() {
let (mut s, placed) = turned_part(true);
let second = run(&mut s, "occurrence.copy", json!({"component": "Part", "translate": [0, 40, 0]}))["occurrence"].as_u64().unwrap();
let top = solidcraft_doc::apply_point(&placed, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
let mut first = s.scratch();
sketch_on_face(&mut first, top, |s, c, _| {
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
});
run(&mut first, "solid.extrude", json!({"distance": -3, "operation": "cut"}));
let world = first.world_state();
let placements: Vec<f64> =
world.bodies.iter().filter(|b| b.definition_name() == "Stock").map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).collect();
let want = 1000.0 - std::f64::consts::PI * 4.0 * 3.0;
assert_eq!(placements.len(), 2, "{placements:?}");
assert!(placements.iter().all(|v| (v - want).abs() < 1e-2), "{placements:?}");
let (_, moved) = s.doc.path_placement(&[second]).unwrap();
let top = solidcraft_doc::apply_point(&moved, solidcraft_geom::Vec3::new(5.0, 5.0, 10.0));
sketch_on_face(&mut s, top, |s, c, _| {
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
});
let features = s.doc.features.len();
for operation in ["cut", "join"] {
let refused = s.execute("solid.extrude", &json!({"distance": -3, "operation": operation})).unwrap_err().to_string();
assert!(refused.contains("placed more than once") && refused.contains("activate"), "{refused}");
}
assert_eq!(s.doc.features.len(), features);
assert!((body_volume(&s, "Stock") - 1000.0).abs() < 1e-6);
}
/// #5679: with the top level's own body as the target, nothing changes: a cut from a sketch on
/// its face keeps the default scope (no bodies stored), cuts it, and leaves the part alone.
#[test]
fn a_sketch_on_a_top_level_body_beside_a_turned_part_cuts_only_that_body() {
let (mut s, _) = turned_part(true);
sketch_on_face(&mut s, solidcraft_geom::Vec3::new(-30.0, -30.0, 5.0), |s, c, _| {
run(s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
});
assert_eq!(crate::auto_operation(&s, &json!({"distance": -2})), Some("cut"));
assert_eq!(crate::auto_operation(&s, &json!({"distance": 2})), Some("join"));
let made = run(&mut s, "solid.extrude", json!({"distance": -2, "operation": "auto"}));
let feature = s.doc.feature(made["feature"].as_u64().unwrap()).unwrap();
assert!(feature.participants.is_empty(), "{:?}", feature.participants);
let hole = std::f64::consts::PI * 4.0 * 2.0;
assert!((body_volume(&s, "Base") - (2000.0 - hole)).abs() < 1e-3 * hole, "{}", body_volume(&s, "Base"));
assert!((body_volume(&s, "Stock") - 1000.0).abs() < 1e-6);
}
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
+42 -21
View File
@@ -36,6 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("path? (default: current file); 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)
.icon("save")
.key("Ctrl+Shift+S")
.noundo()
.params("path; 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")
@@ -304,8 +305,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(stem) = default_name_for(&doc.name, path) {
doc.name = stem;
}
s.replace_design(Session::opened(doc, saved.as_ref()));
s.path = Some(path.to_string());
s.replace_design(Session::opened_at(doc, saved.as_ref(), Some(path)));
s.disk = disk;
s.read_only = read_only;
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
@@ -402,16 +402,7 @@ fn write_file(s: &mut Session, path: &str, format: &str, compression: &str, prev
"compact" => solidcraft_io::DesignCompression::Compact,
_ => return Err(bad("file.save", "compression must be fast or compact")),
};
// Linked designs are kept relative to the folder the design is saved in.
let links = super::component_link::rebased(&s.doc, s.path.as_deref(), path);
let old_links = (links != s.doc.links).then(|| std::mem::replace(&mut s.doc_mut().links, links));
let r = write_bytes(s, path, format, compression, preview, cache);
if r.is_err()
&& let Some(old) = old_links
{
s.doc_mut().links = old;
}
r
write_bytes(s, path, format, compression, preview, cache)
}
fn write_bytes(
@@ -423,8 +414,8 @@ fn write_bytes(
cache: bool,
) -> Result<Value> {
let bytes = match format {
"container" => s.design_container(compression, preview, cache)?,
"json" => solidcraft_io::write_design(&s.design()),
"container" => s.design_container_at(Some(path), compression, preview, cache)?,
"json" => solidcraft_io::write_design(&s.design_at(Some(path))),
_ => return Err(bad("file.save", "format must be container or json")),
};
// Atomic, keeping the version being replaced as `<file>.bak`.
@@ -437,18 +428,39 @@ fn write_bytes(
}
impl Session {
/// Open a design with its file location, resolving component links before history keeps
/// them. Absolute in-memory targets survive Save As, undo, redo and recovery unchanged.
pub fn opened_at(mut doc: Document, saved: Option<&solidcraft_doc::SavedModel>, path: Option<&str>) -> Self {
super::component_link::resolve_design_links(&mut doc, path);
let mut session = Self::opened(doc, saved);
session.path = path.map(str::to_owned);
session
}
/// Prepare only the file's copy with relative paths; saving never rewrites live history.
pub(crate) fn design_at(&self, path: Option<&str>) -> std::borrow::Cow<'_, Document> {
let mut design = std::borrow::Cow::Borrowed(&*self.doc);
if let Some(path) = path {
let links = super::component_link::rebased(&self.doc, self.path.as_deref(), path);
if links != self.doc.links {
design.to_mut().links = links;
}
}
if !self.visibility.is_empty() {
design.to_mut().visibility = self.visibility.clone();
}
design
}
/// Take up a save that ran on `done`, a [`Session::scratch`] copy of this session that
/// `file.save` or `file.save_as` ran on (on another thread, so the window keeps drawing)
/// while this one did not change: the name and link paths the save gave the design, its
/// while this one did not change: the name the save gave the design, its
/// file, and its saved state, as running the command here would have left them.
pub fn adopt_save(&mut self, done: &Session) {
if done.doc.name != self.doc.name {
let (from, to) = (self.doc.name.clone(), done.doc.name.clone());
rename_design(self, &from, &to);
}
if done.doc.links != self.doc.links {
self.doc_mut().links.clone_from(&done.doc.links);
}
self.path.clone_from(&done.path);
self.disk = done.disk;
self.read_only = done.read_only;
@@ -467,7 +479,17 @@ impl Session {
/// Container bytes for desktop and browser saves. Only already evaluated bodies
/// and warm meshes are eligible; saving never forces evaluation or tessellation.
pub fn design_container(&self, compression: solidcraft_io::DesignCompression, preview: bool, cache: bool) -> solidcraft_io::Result<Vec<u8>> {
let design = self.design();
self.design_container_at(self.path.as_deref(), compression, preview, cache)
}
fn design_container_at(
&self,
path: Option<&str>,
compression: solidcraft_io::DesignCompression,
preview: bool,
cache: bool,
) -> solidcraft_io::Result<Vec<u8>> {
let design = self.design_at(path);
let geometry = cache.then(|| saved_geometry(self, &design)).flatten();
let preview = preview.then(|| saved_preview(self)).flatten();
let thumbnail = cache.then(|| thumbnail::saved(self)).flatten();
@@ -708,8 +730,7 @@ fn recover(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(stem) = entry.path.as_deref().and_then(|path| default_name_for(&doc.name, path)) {
doc.name = stem;
}
s.replace_design(Session::new(doc));
s.path = entry.path.clone();
s.replace_design(Session::opened_at(doc, None, entry.path.as_deref()));
s.mark_unsaved();
if bool_(p, "discard").unwrap_or(false) {
crate::recovery::remove(&dir, id)?;
+1 -1
View File
@@ -30,7 +30,7 @@ macro_rules! profile_doc {
}
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("frame.member", "Frame Member", member).at("SOLID", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!(
CommandSpec::new("frame.member", "Frame Member", member).at("WELDMENTS", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!(
"sketch: the frame sketch (id or name; 2D or 3D); lines?: [curve ids] (one member each; default: every straight line drawn in the sketch: not construction, not projected or otherwise linked, not of no length); ",
profile_doc!(),
" | profile_sketch: a sketch whose profile is the section, in its own x and y around its origin, with profiles?: picks as solid.extrude takes them (default: its material, nested loops alternating, so two circles make a tube); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point that sits on each line, default origin); angle?: angle expr (the profile turned about the line; default 0: its y as near world up as the line allows, a vertical member's x along world X); mirror?: bool (the profile mirrored across its y axis); name?; body_name? (what the members are named after, then numbered: `Rail`, `Rail (2)`…; default the profile's description, `Rect Tube 40x40x3`, or the profile sketch's name); names?: {line id: name} (names to keep, as Edit Feature keeps them). One body per line, from its start to its end; each member keeps its name when other lines are added or deleted. Returns the feature and the bodies it made"
+64 -1
View File
@@ -1,5 +1,10 @@
//! Inspection: measure bodies, describe the document, sketches and model topology.
use std::collections::BTreeMap;
use std::hash::{BuildHasher, RandomState};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, OnceLock, Weak};
use serde_json::{Value, json};
use solidcraft_sketch::CurveKind;
@@ -18,6 +23,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.params("bodies?: [names] (default all) → closed per body; volume_mm3, center_of_mass, density_g_cm3 and mass_g only for closed, consistently oriented bodies; area, bbox and counts for all; total.open_bodies_skipped counts bodies excluded from volume/mass totals; or items: [1-2 selections] (face/edge: type, body, point; index optional) → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) and angle"),
CommandSpec::new("document.inspect", "Inspect Design", inspect).icon("inspect").noundo().params("measure?: bool (include body measurements)"),
CommandSpec::new("document.revision", "Design Revision", revision).icon("inspect").noundo().params(
"→ document_id (this open design's id: kept while it stays open, another for each design started or opened), \
revision (moves whenever the design, what is hidden or a component's uncaptured position changes, \
never on a selection or a view; from one counter for the whole run, so it never repeats). Read-only",
),
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).icon("inspect").noundo().params("sketch?: id|name (default active)"),
CommandSpec::new("model.edges", "List Edges", model_edges).icon("inspect").noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).icon("inspect").noundo().params(
@@ -226,7 +236,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
"constraints": constraints,
"profiles": profiles,
"links": sk.links,
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link})).collect::<Vec<_>>(),
"wires": sk.wires.iter().map(|w| json!({"id": w.id, "points": w.pts.len(), "start": w.pts.first(), "end": w.pts.last(), "link": w.link, "arc": w.arc.map(|a| json!({"center": a.center, "radius": a.radius, "start": a.start, "end": a.end, "sweep_deg": solidcraft_sketch::arc_sweep(&a).to_degrees()}))})).collect::<Vec<_>>(),
"visible": crate::view::sketch_shown(s, id),
}))
}
@@ -241,6 +251,59 @@ fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
sketch_json(s, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
}
/// Revisions of every design of this run (#6646).
static NEXT_REVISION: AtomicU64 = AtomicU64::new(1);
/// The design as `document.revision` last saw it, which its revision names.
pub(crate) struct Followed {
/// Held weakly: a design something holds weakly is never changed in place (`Arc::make_mut`
/// moves it), and its address can't go to another design meanwhile.
doc: Weak<solidcraft_doc::Document>,
visibility: crate::Visibility,
pending_moves: BTreeMap<u64, solidcraft_doc::Mat>,
revision: u64,
}
/// A new design's id: one for this run, then a number, so no two designs of one run, or of two
/// runs, have the same.
pub(crate) fn new_document_id() -> String {
static RUN: OnceLock<u64> = OnceLock::new();
static NEXT: AtomicU64 = AtomicU64::new(1);
let run = RUN.get_or_init(|| {
let started = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
// Hashed with this process's random keys (where the system has them), so two runs differ
// even with the clock set back.
RandomState::new().hash_one(started)
});
format!("{run:016x}-{}", NEXT.fetch_add(1, Ordering::Relaxed))
}
impl Session {
/// The revision of what the design holds now (`document.revision`), a new one when it changed
/// since last asked. Changes between two asks that end where they began (an edit undone) need
/// no new one: the design is the one that revision named.
pub fn design_revision(&mut self) -> u64 {
let seen = |f: &Followed| {
std::ptr::eq(f.doc.as_ptr(), Arc::as_ptr(&self.doc)) && f.visibility == self.visibility && f.pending_moves == self.pending_moves
};
match self.followed.as_ref().filter(|f| seen(f)) {
Some(f) => f.revision,
None => {
let revision = NEXT_REVISION.fetch_add(1, Ordering::Relaxed);
let (doc, visibility, pending_moves) = (Arc::downgrade(&self.doc), self.visibility.clone(), self.pending_moves.clone());
self.followed = Some(Followed { doc, visibility, pending_moves, revision });
revision
}
}
}
}
/// `document.revision`: the design's id and [`Session::design_revision`].
fn revision(s: &mut Session, _p: &Value) -> Result<Value> {
let revision = s.design_revision();
Ok(json!({"document_id": s.document_id, "revision": revision}))
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let (vals, perr) = s.doc.param_values();
+59
View File
@@ -667,6 +667,65 @@ fn slotted_arm_drives(yaw_first: bool, gap: f64) {
assert!(arm > 19.9 && arm <= 20.0 + 1e-9, "yaw first {yaw_first}, gap {gap}: the arm stopped at {arm}°");
}
/// The four-bar with its rocker a rod on an axle (a cylindrical joint), and the rocker pin
/// joining the coupler to the rod 4 mm up the axle from where the rod sits, its axis turned
/// `tilt` rad from the others about Y. Returns the closing joint's answer and the rocker pin.
fn rod_on_an_axle(tilt: f64) -> (Session, Value, Vec3) {
let mut s = Session::default();
let (o2, o4, p0) = (Vec3::ZERO, Vec3::new(45.0, 0.0, 0.0), Vec3::new(0.0, 20.0, 0.0));
let [q0, _] = meet(p0, 50.0, o4, 40.0);
for (name, c) in [("Ground", o2), ("Crank", p0), ("Coupler", (p0 + q0) * 0.5), ("Rod", q0)] {
part(&mut s, name, [c.x, c.y, -5.0]);
}
let g = occ(&s, "Ground");
run(&mut s, "occurrence.ground", json!({"occurrence": g, "grounded": true}));
pin(&mut s, "revolute", "Crank", "Ground", "Crank", o2, Vec3::Z);
pin(&mut s, "revolute", "Crank pin", "Crank", "Coupler", p0, Vec3::Z);
pin(&mut s, "cylindrical", "Axle", "Ground", "Rod", o4, Vec3::Z);
let z = [tilt.sin(), 0.0, tilt.cos()];
let r = run(
&mut s,
"joint.create",
json!({"type": "revolute", "a": {"occurrence": "Coupler", "point": [q0.x, q0.y, 0.0], "z": z},
"b": {"occurrence": "Rod", "point": [q0.x, q0.y, 4.0], "z": z}, "flip": true, "name": "Rocker pin"}),
);
(s, r, q0)
}
/// Axes of holes picked on parts are seldom exactly parallel (#6211: a few hundredths of a
/// microradian apart on a lever). A closed loop through them leaves a gap of nanometres no value
/// takes out; it still closes, the rod sliding down its axle, and follows its crank. A tilt that
/// shows still locks it.
#[test]
fn a_loop_whose_axes_are_a_hair_out_of_parallel_still_moves() {
let (mut s, r, q0) = rod_on_an_axle(1e-7);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
let up = Vec3::new(0.0, 0.0, 4.0);
let close = |a: Vec3, b: Vec3| a.dist(b) < 1e-4;
assert!(close(place(&s, "Rod", q0 + up), q0), "{:?}", place(&s, "Rod", q0 + up));
for part in ["Crank", "Coupler", "Rod"] {
assert_eq!(dof_of(&mut s, part), 1, "{part}");
}
for v in [30.0, 60.0, -45.0] {
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": v}));
assert!(r["stopped_by_loop"].is_null() && r["conflicts"].as_array().map(Vec::len) == Some(0), "{v}°: {r}");
let q = rocker_pin(0.0, v, q0);
assert!(close(place(&s, "Rod", q0 + up), q), "{v}°: {:?} wants {q:?}", place(&s, "Rod", q0 + up));
}
// Dragged by its crank pin (the viewport's drag of a part joints hold), the rod follows too.
let (at, th) = (place(&s, "Crank", Vec3::new(0.0, 20.0, 0.0)), 110f64.to_radians());
let r = run(&mut s, "joint.reach", json!({"occurrence": "Crank", "at": [at.x, at.y, at.z], "point": [20.0 * th.cos(), 20.0 * th.sin(), 0.0]}));
assert!(r["reached"] == json!(true) && r["stopped_by_loop"].is_null(), "{r}");
let q = rocker_pin(0.0, 20.0, q0);
assert!(close(place(&s, "Rod", q0 + up), q), "reach: {:?} wants {q:?}", place(&s, "Rod", q0 + up));
// Tilted a milliradian, the loop is locked: no longer planar, it can't turn.
let (mut s, r, _) = rod_on_an_axle(1e-3);
assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}");
assert_eq!(dof_of(&mut s, "Rod"), 0);
let r = run(&mut s, "joint.drive", json!({"joint": "Crank", "value": 30}));
assert!(r["stopped_by_loop"].is_object() && r["values"][0]["value"].as_f64().is_some_and(|v| v.abs() < 0.1), "{r}");
}
/// A joint frame on an occurrence, by origin and axes.
fn framed(o: &str, origin: [f64; 3], z: [f64; 3], x: [f64; 3]) -> Value {
json!({"occurrence": o, "frame": {"origin": origin, "z": z, "x": x}})
+86 -11
View File
@@ -1,11 +1,12 @@
//! Design properties (#1015): `document.metadata` reads the design's `metadata` object, and
//! `document.metadata.set` and `document.metadata.remove` change it, each in one undo step. The
//! keys SolidCraft reads, and the checks a change gets, are `solidcraft_doc::metadata`'s; keys
//! other tools wrote are kept as they are. The UI's Design Properties dialog, scripts and MCP
//! agents all go through these commands.
//! keys SolidCraft reads, and the checks a change gets, are `solidcraft_doc::metadata`'s
//! (`KEYS`, the rows the design takes); keys other tools wrote are kept as they are. The UI's Design
//! Properties dialog, scripts and MCP agents all go through these commands. A change evaluates
//! nothing: properties are no geometry.
use serde_json::{Map, Value, json};
use solidcraft_doc::metadata::{MAX_KEYS, WELL_KNOWN};
use solidcraft_doc::metadata::{self, MAX_KEYS, Owner};
use super::CommandSpec;
use crate::params::{bad, str_};
@@ -13,13 +14,13 @@ use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("document.metadata", "Design Properties", read).icon("settings").noundo().doc_only().params(
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: project, part_number, revision, \
author, organization, description and license, each its text or null (absent, or not text)",
"→ metadata: the design's metadata object as saved (other tools' keys too); properties: each of SolidCraft's design \
properties (project, part number, revision…), its text or null (absent, or not text)",
),
CommandSpec::new("document.metadata.set", "Change Design Properties", set).icon("settings").doc_only().params(
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys: project, part_number, \
revision, author, organization, description, license (text; \"\" or null removes); another tool's data goes under its \
reverse-DNS key such as com.example.tool (an object; null removes) → metadata, changed: [keys]",
"key and value, or values: {key: value, …} (all or nothing, one undo step). SolidCraft's keys, the ones document.metadata's \
properties lists, take text (\"\" or null removes); another tool's data goes under its reverse-DNS key such as \
com.example.tool (an object; null removes) → metadata, changed: [keys]",
),
CommandSpec::new("document.metadata.remove", "Remove Design Property", remove)
.icon("delete")
@@ -29,7 +30,7 @@ pub static COMMANDS: &[CommandSpec] = &[
fn read(s: &mut Session, _: &Value) -> Result<Value> {
let m = &s.doc.metadata;
let properties: Map<String, Value> = WELL_KNOWN.iter().map(|k| (k.to_string(), m.text(k).map_or(Value::Null, |t| json!(t)))).collect();
let properties: Map<String, Value> = metadata::keys(Owner::Design).map(|k| (k.name.to_string(), json!(m.text(k.name)))).collect();
Ok(json!({"metadata": m, "properties": properties}))
}
@@ -48,7 +49,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
let mut next = s.doc.metadata.clone();
let mut changed = Vec::new();
for (key, value) in entries {
if next.set(key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
if next.set(Owner::Design, key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
changed.push(key.to_string());
}
}
@@ -252,6 +253,80 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn changing_design_properties_does_not_evaluate_the_model_again() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
s.evaluations = 0;
let revision = s.revision;
run(&mut s, "document.metadata.set", json!({"values": {"author": "Jane", "com.acme.tool": {"id": 7}}}));
run(&mut s, "document.metadata.remove", json!({"key": "com.acme.tool"}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.evaluations, 0, "design properties change no geometry");
assert!(s.revision > revision, "the views still see each change");
assert_eq!(s.model.state().bodies.len(), 1, "the model is kept as it was");
assert_eq!(s.doc.metadata.author(), Some("Jane"));
// Anything else still evaluates.
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5}));
assert!(s.evaluations > 0);
}
#[test]
fn opening_a_design_that_differs_only_in_its_properties_refreshes_it() {
// Two revisions of one assembly, B held on A by a rigid joint. The open session's B was
// moved off the joint and never solved since; the file is the same design with another
// author. Opening it is no property edit: the new session's joints are solved.
let dir = scratch("revision");
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 20)] {
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": name}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
let placement =
|s: &Session, name: &str| s.doc.find_component(name).and_then(|c| s.doc.occurrence_of(c)).map(|o| (o.id, o.transform)).unwrap();
let a = placement(&s, "A").0;
run(&mut s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}}));
let (b, solved) = placement(&s, "B");
let mut unsolved = (*s.doc).clone();
unsolved.name = "Bracket".into();
if let Some(o) = unsolved.occurrences.iter_mut().find(|o| o.id == b) {
o.transform[3][0] += 3.0;
}
let mut s = Session::new(unsolved.clone());
let mut revision_c = unsolved;
revision_c.metadata.set(Owner::Design, "author", json!("Jane")).unwrap();
let path = dir.join("bracket-rev-c.json");
std::fs::write(&path, revision_c.to_json()).unwrap();
let (read, _) = solidcraft_io::read_design_file(path.to_str().unwrap(), None::<fn(solidcraft_io::DesignPreview)>).unwrap();
assert!(read.differs_only_in_part_properties(&s.doc), "the two revisions differ only in their properties");
run(&mut s, "doc.open", json!({"path": path}));
assert_eq!(s.doc.metadata.author(), Some("Jane"));
let opened = s.doc.occurrences.iter().find(|o| o.id == b).map(|o| o.transform).unwrap();
let off = opened.iter().flatten().zip(solved.iter().flatten()).map(|(p, q)| (p - q).abs()).fold(0.0, f64::max);
assert!(off < 1e-6, "the opened design's joint is solved: B is {off} mm off it");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_key_the_design_took_before_is_still_taken() {
let mut s = Session::default();
let keys = ["project", "part_number", "revision", "author", "organization", "description", "license"];
let values: Map<String, Value> = keys.iter().map(|k| (k.to_string(), json!(format!("{k} text")))).collect();
let r = run(&mut s, "document.metadata.set", json!({ "values": values }));
assert_eq!(r["changed"].as_array().map(Vec::len), Some(keys.len()), "{r}");
let properties = run(&mut s, "document.metadata", json!({}))["properties"].clone();
for k in keys {
assert_eq!(properties[k], format!("{k} text"), "{k}");
}
assert_eq!(properties.as_object().map(Map::len), Some(keys.len()), "{properties}");
let refused = s.execute("document.metadata.set", &json!({"key": "colour", "value": "red"})).unwrap_err().to_string();
assert!(refused.contains("`colour` is not a design property: SolidCraft's are project, part_number,"), "{refused}");
}
#[test]
fn inspect_lists_the_metadata() {
let mut s = Session::default();
+2 -1
View File
@@ -44,10 +44,11 @@ pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
pub use view_dxf::sketch::exportable as view_dxf_exportable;
pub use view_navigation::standard_view;
mod component_link;
pub use component_link::resolve_design_links;
mod config;
mod file;
mod frame;
mod inspect;
pub(crate) mod inspect;
mod interference;
pub(crate) mod joints;
mod libraries;
+197
View File
@@ -0,0 +1,197 @@
//! An axis on an edge or round face of the model (`{edge|face: [x,y,z]}`): Revolve, Surface
//! Revolve, Circular Pattern and Plane at Angle turn about a circular edge's or a cylinder's
//! axis, and follow it when a parameter moves the cylinder.
use std::f64::consts::PI;
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
use crate::view::construction_planes;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
/// A Ø10 × 10 shaft standing on XY at x = `cx` (a parameter, 40 mm).
fn shaft() -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "cx", "expression": "40 mm"}));
run(&mut s, "solid.cylinder", json!({"base": ["cx", 0, 0], "radius": 5, "height": 10, "body_name": "Shaft"}));
s
}
const SHAFT: f64 = PI * 25.0 * 10.0;
/// A 2 × 6 rectangle on XZ, x 46…48, z 2…8: 6 to 8 mm from the shaft's axis.
fn ring_profile(s: &mut Session) {
run(s, "sketch.create", json!({"plane": "XZ", "name": "Profile"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [46, -2], "p1": [48, -8]}));
run(s, "sketch.finish", json!({}));
}
/// Pappus: a 2 × 6 section whose centre is `r` from the axis, turned a full turn.
fn ring(r: f64) -> f64 {
2.0 * PI * r * 12.0
}
#[test]
fn revolve_about_a_circular_edge_follows_the_cylinder() {
let mut s = shaft();
ring_profile(&mut s);
// The shaft's top rim.
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"edge": [45, 0, 10]}, "operation": "new", "name": "Turn"}));
assert!(rel(volume(&mut s), SHAFT + ring(7.0)) < 1e-4, "turned about the shaft's axis: {}", volume(&mut s));
run(&mut s, "parameters.change", json!({"name": "cx", "expression": "40.5 mm"}));
assert!(rel(volume(&mut s), SHAFT + ring(6.5)) < 1e-4, "follows the shaft: {}", volume(&mut s));
// Saved and opened again it still follows.
let saved = s.doc.to_json();
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
assert!(rel(volume(&mut reopened), volume(&mut s)) < 1e-9);
run(&mut reopened, "parameters.change", json!({"name": "cx", "expression": "40 mm"}));
assert!(rel(volume(&mut reopened), SHAFT + ring(7.0)) < 1e-4);
}
#[test]
fn revolve_about_a_cylinder_wall() {
let mut s = shaft();
ring_profile(&mut s);
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"face": [45, 0, 5]}, "operation": "new"}));
assert!(rel(volume(&mut s), SHAFT + ring(7.0)) < 1e-4, "{}", volume(&mut s));
let Some(solidcraft_doc::Feature { kind: solidcraft_doc::FeatureKind::Revolve { axis, .. }, .. }) = s.doc.features.last() else {
panic!("no revolve")
};
assert!(matches!(axis, solidcraft_doc::AxisRef::Model { .. }), "kept as a reference: {axis:?}");
}
#[test]
fn surface_revolve_about_a_circular_edge() {
let mut s = shaft();
ring_profile(&mut s);
run(&mut s, "surface.revolve", json!({"sketch": "Profile", "axis": {"edge": [45, 0, 0]}, "angle": "180 deg"}));
run(&mut s, "parameters.change", json!({"name": "cx", "expression": "41 mm"}));
let st = s.world_state();
// A half turn about x = 41: the far side of the profile ends up at x = 41 − 7 ± 1.
let b = st.bodies.last().map(|b| b.mesh().bounds()).unwrap();
assert!((b.min.x - (41.0 - 7.0)).abs() < 1e-3, "{b:?}");
assert!((b.max.x - 48.0).abs() < 1e-3, "{b:?}");
}
#[test]
fn circular_pattern_about_a_boss_wall_follows_the_boss() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "cx", "expression": "100 mm"}));
run(&mut s, "solid.box", json!({"length": 200, "width": 200, "height": 5, "corner": [0, 0, 0], "body_name": "Plate"}));
run(&mut s, "solid.cylinder", json!({"base": ["cx", 100, 0], "radius": 10, "height": 20, "operation": "join", "targets": ["Plate"]}));
run(&mut s, "solid.hole", json!({"position": [130, 100, 5], "diameter": 2, "name": "Hole"}));
run(&mut s, "solid.pattern.circular", json!({"features": ["Hole"], "axis": {"face": [110, 100, 10]}, "count": 4, "name": "Ring"}));
let holes = |s: &Session, cx: f64, r: f64| {
let st = s.world_state();
let body = &st.bodies[0].body;
[(r, 0.0), (0.0, r), (-r, 0.0), (0.0, -r)].iter().all(|(dx, dy)| !body.contains(Vec3::new(cx + dx, 100.0 + dy, 2.5)))
};
assert!(holes(&s, 100.0, 30.0), "four holes round the boss");
run(&mut s, "parameters.change", json!({"name": "cx", "expression": "90 mm"}));
assert!(holes(&s, 90.0, 40.0), "the copies turn about the boss where it is now");
let st = s.world_state();
assert!(st.bodies[0].body.contains(Vec3::new(100.0, 130.0, 2.5)), "not where the boss was");
}
#[test]
fn plane_at_angle_about_a_cylinder_axis_follows_it() {
let mut s = shaft();
run(&mut s, "construct.plane.at_angle", json!({"base": "XZ", "axis": {"face": [45, 0, 5]}, "angle": "90 deg", "name": "Square"}));
let plane = |s: &Session| construction_planes(s).into_iter().find(|p| p.1 == "Square").map(|p| p.2).unwrap();
let pl = plane(&s);
assert!(pl.normal().cross(Vec3::X).len() < 1e-9, "turned square to XZ about Z: {pl:?}");
assert!(pl.height(Vec3::new(40.0, 0.0, 0.0)).abs() < 1e-6, "through the shaft's axis: {pl:?}");
run(&mut s, "parameters.change", json!({"name": "cx", "expression": "25 mm"}));
assert!(plane(&s).height(Vec3::new(25.0, 0.0, 0.0)).abs() < 1e-6, "follows the shaft");
}
#[test]
fn plane_at_angle_on_a_face_about_an_edge_follows_both() {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "h", "expression": "20 mm"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "h", "name": "Base"}));
// The top face, turned 45° about its front edge (y = 0, along X).
run(
&mut s,
"construct.plane.at_angle",
json!({"base": {"face": [5, 5, 20]}, "axis": {"edge": [20, 0, 20]}, "angle": "45 deg", "name": "Slanted"}),
);
let feature = s.doc.find_feature("Slanted").unwrap();
let solidcraft_doc::FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AtAngle { base, line, .. } } = &feature.kind else {
panic!("at_angle plane: {:?}", feature.kind)
};
assert!(matches!(base.as_ref(), solidcraft_doc::PlaneRef::Face { anchor: Some(_), .. }), "{base:?}");
assert!(line.is_some(), "the edge is kept as a model reference");
let plane = |s: &Session| construction_planes(s).into_iter().find(|p| p.1 == "Slanted").map(|p| p.2).unwrap();
let half = 0.5_f64.sqrt();
let pl = plane(&s);
assert!(pl.height(Vec3::new(20.0, 0.0, 20.0)).abs() < 1e-6, "through the edge: {pl:?}");
assert!((pl.normal().z.abs() - half).abs() < 1e-9 && pl.normal().x.abs() < 1e-9, "45° to the top: {pl:?}");
// A taller box: the face and the edge both move up.
run(&mut s, "parameters.change", json!({"name": "h", "expression": "30 mm"}));
let pl = plane(&s);
assert!(pl.height(Vec3::new(20.0, 0.0, 30.0)).abs() < 1e-6, "follows the edge: {pl:?}");
assert!((pl.normal().z.abs() - half).abs() < 1e-9, "{pl:?}");
}
#[test]
fn the_axis_keeps_its_turn_whichever_way_the_edge_runs() {
// The top and bottom rims run opposite ways round the shaft; a quarter turn about either
// lands in the same place.
let quarter = |edge: [f64; 3]| {
let mut s = shaft();
ring_profile(&mut s);
run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"edge": edge}, "angle": "90 deg", "operation": "new"}));
let st = s.world_state();
st.bodies.last().map(|b| b.mesh().bounds()).unwrap()
};
let (top, bottom) = (quarter([45.0, 0.0, 10.0]), quarter([45.0, 0.0, 0.0]));
assert!(top.min.dist(bottom.min) < 1e-6 && top.max.dist(bottom.max) < 1e-6, "{top:?} vs {bottom:?}");
}
#[test]
fn hostile_model_axes_are_refused_without_panicking() {
let mut s = shaft();
ring_profile(&mut s);
let before = s.doc.to_json();
for axis in [
json!({"edge": [1e9, 0, 0]}),
json!({"face": [1e300, -1e300, 0]}),
json!({"edge": "top"}),
json!({"face": [45, 0]}),
// The flat top: no axis.
json!({"face": [40, 0, 10]}),
// The shaft's axis doesn't lie in a sketch on XY.
] {
for (cmd, p) in [
("solid.revolve", json!({"sketch": "Profile", "axis": axis})),
("surface.revolve", json!({"sketch": "Profile", "axis": axis})),
("solid.pattern.circular", json!({"bodies": ["Shaft"], "axis": axis, "count": 3})),
("construct.plane.at_angle", json!({"base": "XZ", "axis": axis, "angle": "10 deg"})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
assert_eq!(s.doc.to_json(), before, "{cmd} {p} left the design as it was");
}
}
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Flat"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [46, 2], "p1": [48, 8]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.revolve", &json!({"sketch": "Flat", "axis": {"face": [45, 0, 5]}})).unwrap_err().to_string();
assert!(e.contains("does not lie in the sketch plane"), "{e}");
}
+132 -35
View File
@@ -52,6 +52,55 @@ fn component_create_rejects_unknown_keys_and_accepts_documented_keys() {
assert_eq!(s.active_occurrence_path, Some(vec![occurrence]));
}
#[test]
fn body_and_component_renames_keep_hidden_copies_through_history() {
let mut s = Session::default();
let (component, first) = created(&mut s, "Knob");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Knob"}));
let second = run(&mut s, "occurrence.copy", json!({"component": component, "translate": [20, 0, 0]}))["occurrence"].as_u64().unwrap();
let name = |s: &Session, path: &[u64]| s.occurrence_placement(path).unwrap().bodies[0].world.clone();
let hidden = name(&s, &[second]);
run(&mut s, "browser.visibility", json!({"items": [hidden], "visible": false}));
for (command, params) in [
("body.rename", json!({"body": "Knob", "name": "Grip"})),
("component.rename", json!({"component": component, "name": "Dial"})),
("edit.undo", json!({})),
("edit.undo", json!({})),
("edit.redo", json!({})),
("edit.redo", json!({})),
] {
run(&mut s, command, params);
assert!(s.body_visible(&name(&s, &[first])), "{command}: the first copy stays visible");
assert!(!s.body_visible(&name(&s, &[second])), "{command}: the hidden copy stays hidden");
assert_eq!(s.visibility.hidden_bodies, [name(&s, &[second])], "no stale names after {command}");
}
}
#[test]
fn renaming_a_parent_preserves_hidden_nested_copies_with_ambiguous_labels() {
let mut s = Session::default();
let (parent, first_parent) = created(&mut s, "Assembly");
let (_, child) = created(&mut s, "Knob");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Knob"}));
let second_parent = run(&mut s, "occurrence.copy", json!({"component": parent, "translate": [20, 0, 0]}))["occurrence"].as_u64().unwrap();
let name = |s: &Session, path: &[u64]| s.occurrence_placement(path).unwrap().bodies[0].world.clone();
let hidden = name(&s, &[second_parent, child]);
run(&mut s, "browser.visibility", json!({"items": [hidden], "visible": false}));
for (command, params) in [
("component.rename", json!({"component": parent, "name": "Assembly/Other"})),
("body.rename", json!({"body": "Knob", "name": "Grip (@occ[custom])"})),
("edit.undo", json!({})),
("edit.undo", json!({})),
("edit.redo", json!({})),
("edit.redo", json!({})),
] {
run(&mut s, command, params);
assert!(s.body_visible(&name(&s, &[first_parent, child])), "{command}");
assert!(!s.body_visible(&name(&s, &[second_parent, child])), "{command}");
assert_eq!(s.visibility.hidden_bodies, [name(&s, &[second_parent, child])]);
}
}
#[test]
fn instance_copy_uses_selected_source_transform() {
let mut s = Session::default();
@@ -595,6 +644,42 @@ fn a_linked_design_brings_its_bodies_in_and_follows_edits_to_the_part_file() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #5679: a linked part's bodies come from its design file, so a cut or join from the top level
/// on a sketch on its face is refused with the reason, and the plate stays as the file has it.
#[test]
fn a_cut_or_join_from_the_top_level_into_a_linked_part_is_refused() {
let dir = std::env::temp_dir().join(format!("solidcraft-linked-cut-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let part = dir.join("plate.solidcraft");
saved_box_design(&part, 30.0);
let mut s = Session::default();
run(&mut s, INSERT_DESIGN, json!({"path": part, "translate": [50, 0, 0]}));
let _ = std::fs::remove_dir_all(&dir);
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "sketch.create", json!({"plane": {"face": [55, 10, 30]}, "project_edges": false}));
let plane = s
.doc
.features
.iter()
.rev()
.find_map(|f| match &f.kind {
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(*plane),
_ => None,
})
.unwrap();
let c = plane.to_local(solidcraft_geom::Vec3::new(55.0, 10.0, 30.0));
run(&mut s, "sketch.circle.center", json!({"center": [c.x, c.y], "radius": 2}));
run(&mut s, "sketch.finish", json!({}));
let features = s.doc.features.len();
for (operation, distance) in [("cut", -5), ("join", 5)] {
let refused = s.execute("solid.extrude", &json!({"distance": distance, "operation": operation})).unwrap_err().to_string();
assert!(refused.contains("`Plate`") && refused.contains("linked to a design file"), "{operation}: {refused}");
}
assert_eq!(s.doc.features.len(), features);
let volume = run(&mut s, "inspect.measure", json!({"bodies": ["Plate"]}))["total"]["volume_mm3"].clone();
near(&volume, 6000.0);
}
/// The compact list counts what a component holds and leaves out identity transforms; the full
/// list stays as it was.
#[test]
@@ -687,9 +772,9 @@ fn matrix_move_turns_a_quarter_and_moves() {
}
/// #3223, #2872: a STEP assembly whose part sits two levels down, each level placed, opened and
/// stood up. With the imported root active, as Open leaves it, a circle sketched on the part's
/// top face cuts it through, reads as a Cut dragged in and a Join dragged out, and joins it in
/// one piece instead of starting a body of the root's own.
/// stood up. With the imported root active, as Open leaves it, or the top level, a circle sketched
/// on the part's top face cuts it through, reads as a Cut dragged in and a Join dragged out, and
/// joins it in one piece instead of starting a body of the active component's own.
#[test]
fn a_stood_up_nested_step_part_is_cut_and_joined_from_its_face() {
let dir = std::env::temp_dir().join(format!("solidcraft-nested-stood-{}", std::process::id()));
@@ -705,40 +790,52 @@ fn a_stood_up_nested_step_part_is_cut_and_joined_from_its_face() {
let top = solidcraft_doc::apply_point(&src.doc.component_transform(part), solidcraft_geom::Vec3::new(20.0, 12.0, 10.0));
run(&mut src, "file.export", json!({"path": path, "format": "step"}));
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
let _ = std::fs::remove_dir_all(&dir);
let at = solidcraft_doc::apply_point(&s.doc.component_transform(s.active_component), top);
run(&mut s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
let centre = s
.doc
.features
.iter()
.rev()
.find_map(|f| match &f.kind {
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(plane.to_local(top)),
_ => None,
})
.unwrap();
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 3}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(crate::auto_operation(&s, &json!({"distance": -5})), Some("cut"));
assert_eq!(crate::auto_operation(&s, &json!({"distance": 5})), Some("join"));
let volume = |s: &mut Session| run(s, "inspect.measure", json!({"bodies": ["NestedBlock"]}))["total"]["volume_mm3"].as_f64().unwrap();
let block = 40.0 * 24.0 * 10.0;
let hole = std::f64::consts::PI * 3.0 * 3.0;
// #5679: the same from the top level, where the part is two moved and turned levels down
// (#5678 was this cut silently doing nothing; `auto` read it as a new body).
for top_level in [false, true] {
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
let at = solidcraft_doc::apply_point(&s.doc.component_transform(s.active_component), top);
if top_level {
run(&mut s, "component.activate", json!({"component": "root"}));
}
// The sketch is in the active component's frame: the imported root's, or the world's.
let local = if top_level { at } else { top };
run(&mut s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
let centre = s
.doc
.features
.iter()
.rev()
.find_map(|f| match &f.kind {
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(plane.to_local(local)),
_ => None,
})
.unwrap();
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 3}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(crate::auto_operation(&s, &json!({"distance": -5})), Some("cut"), "top level {top_level}");
assert_eq!(crate::auto_operation(&s, &json!({"distance": 5})), Some("join"), "top level {top_level}");
let volume = |s: &mut Session| run(s, "inspect.measure", json!({"bodies": ["NestedBlock"]}))["total"]["volume_mm3"].as_f64().unwrap();
let block = 40.0 * 24.0 * 10.0;
let hole = std::f64::consts::PI * 3.0 * 3.0;
// The circle is measured as faceted: within a thousandth of what it removes or adds.
for operation in ["cut", "auto"] {
run(&mut s, "solid.extrude", json!({"distance": -20, "operation": operation}));
assert!((block - volume(&mut s) - hole * 10.0).abs() < 1e-3 * hole * 10.0, "{operation}: {}", volume(&mut s));
run(&mut s, "edit.undo", json!({}));
// The circle is measured as faceted: within a thousandth of what it removes or adds.
for operation in ["cut", "auto"] {
run(&mut s, "solid.extrude", json!({"distance": -20, "operation": operation}));
assert!((block - volume(&mut s) - hole * 10.0).abs() < 1e-3 * hole * 10.0, "{top_level} {operation}: {}", volume(&mut s));
run(&mut s, "edit.undo", json!({}));
}
for operation in ["join", "auto"] {
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": operation}));
let st = s.model.state();
assert_eq!(st.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["NestedBlock"], "{top_level} {operation}");
assert_eq!(st.body("NestedBlock").map(|b| b.body.shell_count()), Some(1), "{top_level} {operation}");
assert!((volume(&mut s) - block - hole * 5.0).abs() < 1e-3 * hole * 5.0, "{top_level} {operation}: {}", volume(&mut s));
run(&mut s, "edit.undo", json!({}));
}
}
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["NestedBlock"]);
assert_eq!(st.body("NestedBlock").map(|b| b.body.shell_count()), Some(1));
assert!((volume(&mut s) - block - hole * 5.0).abs() < 1e-3 * hole * 5.0, "join: {}", volume(&mut s));
let _ = std::fs::remove_dir_all(&dir);
}
/// A rotation that is one only up to rounding is orthonormalised; a scale, a shear or a mirror

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