Compare commits

...

45 Commits

Author SHA1 Message Date
bherbruck 586a358ed4 Release 0.23.1 2026-10-26 07:07:21 -04:00
bherbruck 4d61100534 Release 0.23.1: version bump, design format 38 (#7035)
The component colour tests no longer assume a design always carries a list of
pending format changes; once the release folds them, there is none.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:07:18 -04:00
Homero Thompson 4ced1d7bcf Viewport: the grid fades with the distance instead of cutting off (#4214) (#4629)
* claim: #4214

* Viewport: the grid fades with the distance instead of cutting off (#4214)

The ground grid stopped at a fixed distance from the view, so its edge showed
as a cut across the picture. It now fades out with the distance from the
middle of the view, and it is gone before either of the two lines that used to
cut it: its own edge, and the picture's far plane.

Each grid line is drawn in pieces, and each piece is as faint as the fade
makes it at the distance where it lies. The 'under' batch is drawn by the
overlay pipeline with alpha blending, so the colour's alpha reaches the
frame.

* The headless grid fades with the distance too (#4214)

The headless path (solidcraft-cli snapshot, the home-page thumbnail, the MCP
screenshot) drew the grid from the engine's palette, where a line had no
alpha: the grid still ended in a cut, and at a low camera angle the cut was
in the picture.

A SceneLine now carries an alpha, the CPU rasterizer blends with it and the
GPU capture passes it on, so the engine's grid can fade. The fade itself
moves to the engine, next to the grid's other geometry, and both paths use
it: the headless grid now reaches as far on the ground as the view is wide
(the ground stretches as it is seen at a slant) and dissolves into the
distance instead of ending in a line.

* Fix the headless/viewport grid fade divergence (#4214)

The engine stretched the grid's size with the slant of the plane it is seen at, the viewport stretched it only on the ground, so on a sketch's plane the picture's grid reached further than the window's (153.42 mm against 120.00 mm). Both now share one grid_ext, which the picture and the window draw the same grid from.

* The grid fades out before the picture's far plane cuts the ground (#4214)

The window's grid faded against a far plane of its own: the scene's radius
clipped by the projection's camera (260 mm away at 400 mm of distance), while
the picture itself cuts the ground at the far plane of its own projection
(223.7 mm at the same distance, because the projection frames the scene's
bounds and not its radius). The grid was still a third of its strength where
the picture cut the ground, so the cut showed as a straight line across the
view.

The fade now ends at the far plane the picture's projection uses, and the
grid is gone before that plane cuts the ground.

The test that watched the fade now measures the ink of each row of the
picture, and checks that the grid is already gone in the ten rows above the
row where the far plane cuts the ground: the point it compared before lay
beyond that plane, where both renders draw nothing at all, so the comparison
was always true.
2026-10-26 07:59:56 +00:00
DuBearte a82f839255 Docs: don't offer winget install before the package is in winget-pkgs (#5734) (#5827)
* claim: #5734

* Docs: don't offer winget install before the package is in winget-pkgs (#5734)

The README and docs/downloads.md said Windows users could run
`winget install bherbruck.SolidCraft`, but winget-pkgs has no manifests for
bherbruck.SolidCraft (manifests/b/bherbruck doesn't exist), so winget finds
no package. winget.yml only updates a package that is already listed, and
does nothing until then. Drop the command from both pages, and say in
docs/releasing.md that the first version has to be submitted once by hand
and the command added back when it's listed.

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

* Releasing: a first WinGet submission is already open; take it up (#5734)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:39:29 +00:00
Brandon Wees 08484af293 Finish the sketch when Extrude, Revolve or another feature starts (#2802) (#2815)
* fix(ui): finish the open sketch when a modeling feature starts

* fix(ui): wait for the marker roll before starting a feature from a sketch

* fix(ui): start the feature once the marker is back

* test(ui): keep the sketch open in the palette clearance test
2026-10-26 06:56:07 +00:00
raykholo d29e70bad9 docs: Save As blocked by Windows Controlled folder access (#4490)
Saving into Documents fails with the Windows dialog's "File not found" when
Controlled folder access blocks the app, and the message gives no hint.
Refs #4479.
2026-10-26 06:49:53 +00:00
Logan Night 1d482aff74 Docs: AppImageLauncher 2.x can't start the AppImage; update to 3.0 (#4640) (#4674)
* claim: #4640

* Docs: AppImageLauncher 2.x can't start the AppImage; update to 3.0 (#4640)

AppImageLauncher 2.x intercepts every AppImage through binfmt_misc and
can't start static-runtime AppImages such as SolidCraft's: it fails with
"execv error: No such file or directory" after a FUSE message. 3.0 starts
it. One paragraph in the Linux AppImage section says so.

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

* Docs: AppImageLauncher 3.0 is so far only published as betas (#4640)

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
2026-10-26 06:49:52 +00:00
raykholo 2445c0f13d Flatpak repository: always publish its HTTPS URL (#4864) (#4875)
GitHub Pages reports the base URL as http:// for the custom domain, so the
.flatpakrepo file, the remote-add line on the repository page and the mirror
of the previous repository all used plain HTTP. Force https:// once where the
base URL is read.
2026-10-26 06:49:50 +00:00
Homero Thompson 3d0e2a8b3c docs: the gate reads the working tree, and how to show a picture without committing it (#4902)
* docs: two notes for agents and contributors (the gate's working tree, and linking a picture)

* docs: how to show a picture without attaching it

The reviewer asked for the pictures to be attached to the PR (or in a gist),
not hosted on a branch of a fork, so the note no longer teaches the branch
trick.

* docs: say how to attach a picture, and what a gist is for

The review of this pull request pointed at two things in the note on
evidence:

- Attaching works in the browser for anyone who can comment: drag the
  file into the comment. The limit is the command-line agent, whose
  upload needs write access to this repository. Say that, instead of
  claiming that a contributor without write access cannot attach a file.
- A gist suits a log or another text file; it does not render a picture
  inline. Say what it is for, so nobody hits that dead end.

The note in AGENTS.md about the gate reading the working tree is
unchanged.
2026-10-26 06:49:48 +00:00
raykholo 2a01cca00e User guide: local models with the Assistant (#4851) (#4964)
* claim: #4851

* User guide: local models with the Assistant (#4851)

A page on choosing and setting up a local model (Ollama, LM Studio, or any
OpenAI-compatible server) for the Assistant: tool support, VRAM, permissions,
the measured results from the standard test in #4851, and troubleshooting
for the common failures.

* Guide: local models table with the full set of measured results
2026-10-26 06:49:46 +00:00
raykholo d7cc5621fa Troubleshooting: what to do when a design won't open (#5114)
Point to the `<file>.bak` copy every save keeps, the design.solidcraft in crash
reports, the recovery folder for unsaved designs, and how to attach the file
to an issue.
2026-10-26 06:49:44 +00:00
raykholo be6c7fc17c Bug template: ask to reproduce on a current build first (#5150) (#5154)
* claim: #5150

* Bug template: ask to reproduce on a current build first (#5150)
2026-10-26 06:49:42 +00:00
raykholo f006eefd6e docs: point AGENTS.md's starting steps at public files (#6049)
"Start every session here" sent readers to plan/STATUS.md,
plan/execution-plan.md, plan/architecture.md and
plan/adr/0001-geometry-kernel.md. plan/ is gitignored, so nobody who
clones the repository has those files.

Point the steps at what ships instead: ROADMAP.md, CONTRIBUTING.md's
"Where help is wanted most", "How the code is laid out" and "Kernel
work is paused" sections, docs/architecture.md, the help wanted, good
first issue and needs-triage labels, and the licence rules with
deny.toml and cargo xtask licences --check.

Signed-off-by: Ray <raykholo@users.noreply.github.com>
2026-10-26 06:49:38 +00:00
bherbruck 15269f85a1 docs: how to bump the Homebrew tap on a release (#6276)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 06:49:37 +00:00
bherbruck e53390ea1b Roadmap: a short user-facing ROADMAP.md, with the developer detail moved to docs/roadmap-detail.md (#6797)
* Roadmap: a short user-facing ROADMAP.md, developer detail moves to docs/roadmap-detail.md

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

* Roadmap: feature milestones with progress counts, daily releases through the in-app updater

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

* Roadmap: count issues as pieces, show work built on the drawings and render branches

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 06:49:35 +00:00
raykholo c8314687fc Construction points and axes: Browser rows, and pickable in the view and dialogs (#4312) (#4320) 2026-10-26 04:50:46 +00:00
raykholo 878339cac8 Sketch patterns: count, spacing, and angle follow parameters (#5231) (#5332) 2026-10-26 04:31:55 +00:00
bherbruck 348bbc1dfa fix(joints): edge and corner picks follow the part when it changes (#6942)
The Joint dialog kept a round edge (a hole's rim), a straight edge and a
corner as plain points where they were picked. When the part got thicker,
a hole moved or the part got longer, the origin stayed where it was and
the jointed part overlapped the other one. An edit that left the picked
spot where it was (lengthening an arm away from the jointed hole) looked
fine, which hid it. Faces and hole walls were already found by name.

The dialog now sends a round edge as its circle's centre with its z, and a
straight edge or corner as an edge pick with its z. The engine keeps them
by name (the round end by its wall and the face across its rim, the edge
and corner by their edge and face names), with the frame the pick showed;
where it can't (no planar face beside the edge, no names), it keeps the
point as before. Edge origins get an optional axis (along the edge, or a
fixed direction) so a hinge on an edge keeps turning about it.

Tests: every joint type and as-built, for each way the dialog picks an
origin, with the arm made thicker, its hole moved and the arm lengthened,
inside one design and through Update Links.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 04:05:26 +00:00
dudedagger42 262f5c61c0 Move, Align and previews on placed components (#4455)
* Move and Align on placed components: picks and vectors where they are shown

A body in a component placed away from its own frame (an imported part
set upright) moved the wrong way: Move's vector, axis and pivot were
taken in the root's frame, so moving the upright plate up moved it along
its own frame's z. They now turn with the frame of the bodies moved.

Align mapped each pick into the frame of the body under it, so aligning
two separately placed parts mixed two frames (the plate landed at right
angles), and its snap points were never mapped at all. Align now finds
its picks, face normals and snaps where the view shows them and takes
both ends into the moved bodies' frame.

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

* Preview: a feature's tool is drawn where its component is shown

Extrude, revolve and the primitives preview their tool on its own. That
preview was built in the model's frame, so on a component placed away
from it (an imported part set upright, edited with its component
active) the tool showed at right angles to the picked face, though the
feature itself came out right.

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

* Follow main's lazy body frames and first-part grounding in the placed-component work

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 03:28:14 +00:00
Luke Jones ece2a8693a Cache assembly browser data between unchanged frames (#662) (#1610)
* Cache assembly browser data between unchanged frames

Extend #1762's cached component membership, placements and viewport hover
feedback with cached occurrence labels, folder entries, row keys and
tooltips. Skip offscreen row work and reuse browser hover buffers while
reading selection, visibility and locking live. Cache #2681's standard-part
configuration labels with the document snapshot instead of formatting
them on every redraw. Preserve #3009's live selection and hover marks
with highlight keys owned once by cached entries, and retain #2431's
feature rows in the reusable drawing order.

Related to:
- tangency-labs/solidcraft#662
- tangency-labs/solidcraft#1762
- tangency-labs/solidcraft#2681
- tangency-labs/solidcraft#3009
- tangency-labs/solidcraft#2431

Co-authored-by: Codex <noreply@openai.com>

* Browser cache: lock state moved to the session visibility

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

* Browser cache: reveal a placement's body by its row, and drop a needless borrow

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

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:51:32 +00:00
dudedagger42 0f77199449 Pipe: an Operation row, so a pipe can join, cut or intersect (#2820) (#5169)
* Pipe: an Operation row, so a pipe can join, cut or intersect (#2820)

The engine's solid.pipe takes an operation, but the Pipe window had no way to
choose one, so a pipe always made a new body. The window now has the same
Operation row as Loft and the primitives, and editing a pipe keeps its
operation.

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

* Fit the Pipe operation row to main: sketch-qualified pick, length_input pattern

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 02:32:31 +00:00
github-actions[bot] e02ef3246f Regenerate generated files and fold pending files [skip ci] 2026-10-26 03:14:51 +00:00
raykholo ffbeb42b5c Assistant: say so when a turn ends without using SolidCraft's tools (#4846) (#5188)
* claim: #4846

* Assistant: say so when a turn ends without using SolidCraft's tools

When tools were offered and a completed turn made no tool call and no
change, the chat panel shows a short notice pointing to tool-capable
models: always for an empty reply, and for a text-only reply to an
explicit edit request. Plain answers to questions stay quiet. The
control channel exposes it as turnWarning.

* Fix the Anthropic loop test for the tools event; add ja and zh for the no-tool-call notice

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:19:57 +00:00
Matthew Knutson 816bcf10de Every new command comes with input and output schemas (#2829) (#4035)
* Every new command comes with input and output schemas

A test fails for a command without a contract or an output type, unless it is a planned!
placeholder or listed in schema/unconverted.txt, which holds the 400 commands that predate
the rule and only shrinks: the test also fails when a listed command has its schemas, when
the list names a command that doesn't exist, or when it isn't sorted.

`checked!` now takes only the command's id and checks the contract that id publishes
(`run_checked_as`), so the contract enforced is always the one described. Another test runs
every command that has a contract in strict with an unknown key, so a contract is never
published without `checked!`, under the command's own id, enforcing it.

A new book page, Command schemas, explains how to write a contract and a reply type, the
tests to add, the shared field types and how to add one, with a checklist; the failure
messages point to it. "Adding a command", CONTRIBUTING.md and AGENTS.md now include the
schemas, and the MCP tool description and docs/mcp.md no longer list the converted commands.

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

* List the commands added on main since: view.layout, view.history_layout, view.normal_to

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

* Command schemas guide: what participants and targets do, checking the evaluation, warning-free replies

- `participants` changes the result for some features and nothing for others; where it and
  another key override each other, or where it has no effect, say so in that key's
  description or in the summary.
- The example primitive's `targets` default to every body in the feature's component, not the
  bodies it meets; they're ignored when `participants` lists bodies.
- Check what each value does where the feature is evaluated, not only how the handler reads it.
- A reply test runs every form in Warn and checks for no `param_warnings` (Strict for a reply
  that isn't an object).

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

* List the 43 commands added on main since: frame, component, render and others

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

* List the 11 commands added on main since: browser folding, standard views, parameter sweep, path flange

browser.collapse_all, browser.expand_all, inspect.parameter_sweep, sheet.path_flange and the
seven view.* standard views (back, bottom, front, isometric, left, right, top) landed on main
without schemas; the gate flags them, as intended. 457 commands listed.

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

* Schema rule warns instead of failing for now

A command without input and output schemas prints a warning listing it
(test output and a CI annotation). SCHEMA_RULE_STRICT in coverage_tests.rs
(or SOLIDCRAFT_SCHEMA_RULE_STRICT=1) makes it fail again. Docs say "should,
and will become required".

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 01:43:02 +00:00
Nathan Dane 28689e784e fix(ui): retain macOS application bar controls alongside native menus (#6190) 2026-10-25 21:19:57 -04:00
github-actions[bot] d396be78a2 Regenerate generated files and fold pending files [skip ci] 2026-10-25 23:35:40 +00:00
github-actions[bot] cbac207852 Regenerate generated files and fold pending files [skip ci] 2026-10-25 23:29:18 +00:00
taylorhayis ff2ca2d462 Keep focus when correcting an invalid parameter name (#3965) (#4000)
* claim: #3965

* Parameters: a click can correct an invalid name (#3965)

Refusing the new row was putting focus back on the field being left, so the correction was typed into the expression.

* Parameters: the invalid-name test types its expression draft (#3965)

A new row starts with a blank expression, so the regression test types 10 mm and checks that correcting the name leaves that draft in place.
2026-10-25 22:42:30 +00:00
CamWight ab80a410a1 Viewport theme: a light 3D view in a dark window, and the reverse (#4146) (#4147)
* claim: #4146

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

* Viewport theme: a light 3D view in a dark window, and the reverse (#4146)

Preferences › Interface › Theme gets a Viewport row under the theme tiles:
Follow theme (the default, as before), Light or Dark. Light or Dark draws
the 3D view in the built-in Light or Dark theme's colours whatever the
window uses, so a dark window can show the model on a light view and the
other way round; it works with every theme, theme files included.

The view takes the background and grid, bodies, selection and hover, the
origin, previews, sketches with their dimensions and glyphs, the navigation
cube and triads, box selection and the analysis colours. Panels, dialogs,
menus and the chips over the view keep the window's. Text and warnings drawn
straight onto the view (the sketch status line, the loose-end and midpoint
markers, the section's cut outlines, the 3D sketch tool and the sketch tools'
previews) take the view's side, so they read on its background.

theme::ViewportTheme is the setting (saved as `viewport_theme`: theme, light
or dark; an unknown saved value reads as Follow theme, as ColorVision's
does). Tokens::get() takes the view's colours from the other side only when
the view's dark or light differs from the window's; Tokens::view() is the
view's side for colours drawn onto it. The ground shadow follows the view
(the scene and highlight caches already key on the colours themselves),
ui.render draws in the view's colours, and ui.prefs takes `viewport_theme`
and reports `viewport_dark`. An older release ignores the setting and draws
everything in the theme.

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

* Viewport theme: follow the single set of axis colours

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-25 22:17:04 +00:00
leValdemar19 b94692497e Sketch Dimension: arc length from an arc and its end point (#754) (#4245)
* claim: #754

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

* Sketch Dimension: arc length from an arc and its end point (#754)

The arc-length dimension existed (sketch.dimension type arc_length) but
the Dimension tool could only give an arc its radius. An arc and one of
its own end points, in either order, now dimension the arc's length:
click the arc, then its end. The pair was refused before ("cannot
dimension that combination"), so no existing result changes. A point
where the arc's end is (a coincident line end) counts as that end.

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-25 22:17:01 +00:00
Armin 7bcc1ee0eb Drawing: shared number formatter (#5290) (#5315)
* claim: #5290

* doc: add configurable decimal number formatter (#5290)

---------

Co-authored-by: Armin <192400139+armin0411@users.noreply.github.com>
2026-10-25 22:11:39 +00:00
raykholo 36a60a70d9 Graphics: 1x anti-aliasing where the multisampled view draws nothing (#5819, #5870) (#5832) 2026-10-25 22:10:04 +00:00
dudedagger42 5d17fa970b Sketch: hovering a circle's edge wakes its centre (#5043)
* Sketch: hovering a circle's edge wakes its centre

A circle's or arc's centre was hard to take with Line or Dimension: it is a small
point in the middle of empty space, taken only within 8 points of the pointer, and
nothing showed where it was. Hovering a circle or arc of the sketch being edited
now wakes its centre: drawn (a cross in a ring) and taken from 16 points away, so
the pointer can go from the edge across the inside to it. Hovering another curve
or point puts it back to sleep, as does leaving the sketch.

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

* A woken centre's extra reach yields to a curve under the pointer

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-25 21:57:23 +00:00
Frank b5bab3f663 Browser: surface bodies get the surface icon in the Bodies folder (#4165) (#4168)
* Browser: surface bodies get the surface icon in the Bodies folder (#4165)

Every row in the Bodies folder had the solid body icon, so a surface (an
open shell, such as a Surface Revolve of an open curve) looked like a
solid. A body whose shell does not close now shows the existing surface
icon; solids keep the body icon.

Body::is_surface walks every shell's edges, so ModelBody caches it once per
body (shared by clones, like outline_bounds) instead of the browser
checking it every frame.

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

* Add the surface icon to the requested-icons manifest

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-25 21:51:02 +00:00
Steven Huff ccea34e73c sketch: a tangent whose contact is tied by Coincident, PointOnCurve or Midpoint is solved at the contact (#2584)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 21:37:51 +00:00
Dennis Klappe c7f86c7309 Look At in the viewport context menu for a planar face or plane (#4105) (#4497)
* Look At in the viewport context menu for a planar face or plane

Fixes #4105

* Look At tests: turn the camera without the animation

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:37:37 +00:00
raykholo 4355d117ef Assistant: warn when an Ollama model has no tool support (#4846) (#4916)
* claim: #4846

* Warn when Ollama models lack tool support (#4846)

Check Ollama model capabilities in the background and cache results per
endpoint and model. Show an advisory warning for models without tools,
expose it through ui.agent_chat, and document the behavior.

Cover capability parsing, warning selection, background probing, caching,
and selection changes with synthetic regression tests.

* Keep the Ollama capability probe out of the web build

The panel never calls it there, so it would only be dead code.

* Wrap the model warning instead of cutting it off

In a narrow panel the end of the sentence, the part that says what to do, was truncated.

* Fix AgentConfig default after rebase onto main

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

* Move the Ollama no-tools warning into a pending locale file with ja and zh

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:37:30 +00:00
dudedagger42 39951a22ff Loft: a sketch point picked in the window is an apex (#4830) (#5207)
solid.loft already closes to a point (a section {sketch, point}), but the Loft
window's Profiles input took profiles only, so a pyramid or cone tip couldn't
be made from the UI. The input now also takes a finished sketch's point, sent
as an apex section in pick order; profiles of one sketch still make one
section.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:17:14 +00:00
ChrisNo a3890b3bd5 Open a linked part from its assembly Browser row (#2348) (#2349)
* claim: #2348

* Open linked designs from assembly browser

* claim: #2348

* Test linked design action with multiple components selected

* Fix linked design lookup in browser storage and translate its menu action

* Add Turn Up Axis to complete UI catalogs

* Keep browser DXF download after linked-design checks
2026-10-25 21:15:48 +00:00
Vadim Kalinsky 59cad5f0bb Colored components (feature request #2507) (#2877)
* feat: add colored components inspection toggle

* feat: persist component colors and add cycling controls

* feat: assign component colors in creation order

* fix: update component color menu integration and translations

* fix: proper description for pending format change.

* fix: deleted duplicated format change description from docs/pending.

* Compare the component colour counter in the features-only check

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

---------

Co-authored-by: Vadim Kalinsky <vadim@kalinsky.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:11:03 +00:00
Kai Evans 5fbe058051 Sketch: a click picks the sketch's own curve over a projected one under it (#5257) (#5260)
* Sketch: a click picks the sketch's own curve over a projected one under it

* Select Other tests: follow origin visibility rename

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

* Own-curve pick test: click away from the lines' midpoints, which are picks of their own now

* Use the select-other test helpers main already has

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

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:10:10 +00:00
DuBearte 0752d8d79c Extrude: a profile drawn twice is cut once, not as two coincident tools (#5543) (#5789)
* claim: #5543

* Extrude: a profile drawn twice is cut once, not as two coincident tools (#5543)

A circle drawn twice at one place is two profiles of one region. Extruded
together they were two coincident tools: a cut through a 121-hole grid with
four such circles retried the boolean with the tool nudged (four times) and
took 25 s instead of 0.5 s.

merge_regions, which every extrude, revolve and surface feature runs on the
regions it builds from, now leaves out a region whose boundary repeats an
earlier one (within 1e-7 of its size; corners and segment middles on the
other loop both ways, holes alike). The selection itself keeps its
multiplicities, as profile_refs' tests require.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:10:02 +00:00
raykholo 0344a41dc4 Open and insert a DXF outside a sketch (#2052) (#4635) 2026-10-25 21:03:31 +00:00
DuBearte dba8108369 Timeline: a reorder evaluates the design once, from the cache it already has (#6186) (#6187)
* claim: #6186

* Timeline: a reorder evaluates the design once, from the cache it already has (#6186)

timeline.reorder checked the move on a copy of the model, then dropped it,
so setting the reordered design evaluated the moved part of the timeline
again; reorder_many (and moving features into a group) checked on an empty
model, recomputing the whole design even for a move near the end. The
check now starts from the session's own model, and the session keeps the
model it was checked on.

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

* Marker is Copy: drop the clone

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-25 20:55:52 +00:00
bherbruck 57d787f5ba Docs site: canonical links, robots.txt and sitemap.xml (#6888)
Search Console lists docs.solidcraft.io/, /introduction.html (the same page) and /guide/mcp.html as duplicates without a user-selected canonical. The Pages build now adds a canonical link to every book page (index.html and introduction.html both name /), skips the redirect stubs that already have one, marks 404 and the toc frame noindex, and writes a robots.txt pointing at a generated sitemap.xml.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:35:56 -04:00
158 changed files with 7921 additions and 1599 deletions
+1
View File
@@ -6,6 +6,7 @@ body:
attributes:
value: |
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
Before reporting a boolean or modelling failure, please try to reproduce it on the latest nightly or a build from current `main`, and say which build you used.
- type: textarea
id: what
attributes:
+1
View File
@@ -65,6 +65,7 @@ jobs:
run: |
mkdir -p site
cp -r target/book/. site/
bash docs/book/seo.sh site # canonical links, robots.txt, sitemap.xml
cp -r dist/web site/app
- uses: actions/configure-pages@v5
id: pages
+19 -10
View File
@@ -15,14 +15,18 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
`/solidcraft/`; do not add permanent old-name Pages aliases.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section.
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
unless it lists the decision as the owner's.
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.
`plan/` is gitignored (local only).
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.**
@@ -82,7 +86,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- **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`) + tests. The toolbar,
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.
@@ -110,7 +117,9 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
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.
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
@@ -209,7 +218,7 @@ before the draft is verified.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
`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.
+2
View File
@@ -284,6 +284,8 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/design/svg/model/circle_tt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/circle_ttt.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/coil.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/colored_components_off.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/combine.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/component.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/compute.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 |
+18 -3
View File
@@ -11,7 +11,7 @@ build it, how the code is laid out, the few rules that aren't negotiable, and wh
needed most.
Before choosing a task, check the [command inventory](docs/features.md) and
[roadmap](ROADMAP.md) for implemented capabilities and current priorities.
[roadmap](docs/roadmap-detail.md) for implemented capabilities and current priorities.
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
@@ -130,7 +130,9 @@ The [architecture page](docs/architecture.md) has the layer table. The short ver
- Every call into truck goes through `solidcraft_kernel::guard`.
- Every crash fix comes with a regression test.
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
`crates/engine/src/cmd/` with input and output schemas (see
[Adding a command](docs/book/src/dev/commands.md#adding-a-command); a test warns about a
command without them, and will fail it once required), engine tests that measure the result (volume, area,
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
@@ -274,6 +276,9 @@ and a fresh CI run:
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
sorted). Add a new module into the existing group, not as a new group at the end.
- **Commands without schemas** (`crates/engine/src/schema/unconverted.txt`): sorted, one id per
line, and it only shrinks. Converting a command removes its line; a new command is never added
to it.
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
checks it).
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
@@ -334,6 +339,16 @@ Changes with no useful observable behavior do not need screenshot or video evide
small example without private designs or sensitive details; attach the media or link to an
accessible recording rather than committing generated review artifacts.
To show a picture without committing it, attach the media to the PR (it goes to the PR's own
attachment storage and renders inline in the comment). A gist is the better home for a log or
another text file, and you link it; a gist does not render a picture inline. The review artifacts
stay out of the diff that way, and nothing has to be pushed to a branch of its own.
Attaching works in the browser for anyone who can comment: drag the file into a comment on the
pull request. A command-line agent without a browser session usually cannot attach a file (the
upload needs write access to this repository). Say so in the pull request, and the person running
the agent or a maintainer can attach it.
Keep an eye on your PR's status, but don't push to it only to catch up with `main` (no merge of
`main`, no Update branch button, no rebase for that alone): every push restarts the whole CI
run for everyone waiting behind it, and `main`'s own run catches what lands in between. When you
@@ -389,7 +404,7 @@ In order of how much they'd help users:
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
through without a kernel error. Each failing seed is a reproducible bug.
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
[ROADMAP.md](docs/roadmap-detail.md)), but anyone who wants to own one is welcome.
## Updating the generated feature list
Generated
+17 -17
View File
@@ -4208,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4240,11 +4240,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.23.0"
version = "0.23.1"
[[package]]
name = "solidcraft-cli"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"image",
"log",
@@ -4256,7 +4256,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4265,7 +4265,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"log",
"serde",
@@ -4282,7 +4282,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"log",
"schemars",
@@ -4302,7 +4302,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"serde",
"serde_json",
@@ -4311,7 +4311,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"image",
"log",
@@ -4331,7 +4331,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4355,7 +4355,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"log",
"proptest",
@@ -4375,7 +4375,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4384,7 +4384,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"image",
"moxcms",
@@ -4398,7 +4398,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"serde",
"serde_json",
@@ -4409,14 +4409,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"arboard",
"egui",
@@ -4445,7 +4445,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"eframe",
"egui",
@@ -5983,7 +5983,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"proc-macro2",
"serde_json",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.23.0"
version = "0.23.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1 -1
View File
@@ -52,7 +52,7 @@
## Download
- **Windows:** the [installer][windows-msi], or `winget install bherbruck.SolidCraft`.
- **Windows:** the [installer][windows-msi].
- **macOS:** the [disk image][macos-dmg] (Apple silicon and Intel).
- **Linux:** the [AppImage][linux-appimage], or `sudo snap install solidcraft` ([Snap Store][snap-store]).
- **Web:** [try it in your browser][web-app], no install.
+80 -694
View File
@@ -1,696 +1,82 @@
# SolidCraft roadmap
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
so edge-touching assembly neighbours stay separate regardless of body order (#226).
In scope, in priority order:
1. SOLID and SKETCH (now).
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
cycles reported with their path, rename follows references, deletes list their users):
user parameters with units and expressions (+-*/, ^,
parentheses, sin/cos/sqrt/min/max/floor/ceil/round, unit conversion), model parameters
d1, d2… for every dimension and feature input, references between parameters with a
dependency graph and cycle detection, re-evaluation on change, comments, favourites,
CSV/JSON import and export.
3. Components and assemblies with joints (M9).
4. SHEET METAL: flange, contour flange, bend, unfold/refold, flat pattern with DXF export, sheet
metal rules (thickness, K-factor, bend radius) (M10).
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Milestones
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), drawing snaps to where two lines, circles or arcs cross (#4967), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop, browser and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; Browser Expand All / Collapse All (`browser.expand_all`, `browser.collapse_all`) on the design and component menus; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements, and exported as an animation coming apart and back together); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. macOS Developer ID signing and notarization are in the release workflow (#5749; they switch on when the Apple secrets exist). Windows installer signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers
orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler
applies the same gestures to model and Render views and blocks orbit on flat pages.
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
action tiles, recent rows and sample cards, with recovery review and Save As/Export
entry points. Document loading, sample caches and recovery safety retain their
existing command paths.
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
Curves are approximated; drawing sheets,
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
packages. The first geometry increment defines validated section records in a plane-local
frame; the ledger separates that contract from section producers and the Drawing workspace.
Desktop view DXF export runs in the file-operation worker with five-stage progress and
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
completed drawings publish atomically. The browser still exports synchronously.
## Interactive modelling
- Modelled threads preview their cut before OK using the background worker on desktop.
Browser users apply the cut with OK to avoid blocking on every edit.
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
pitch selectors expose the existing metric designation parameter; custom designations stay
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
full/partial-length options remain available. This does not add tolerance classes or
left-hand threads.
- New Body extrusion and face-offset previews show completed intermediate geometry while a
drag continues, then catch up to the exact final value. Changes to the selected geometry,
operation or feature still discard obsolete results. Distance arrows and their value boxes
follow the current frame's input.
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
## Physical material data
- Physical material card validation (#923/#922) provides typed SI mechanical and
thermal data, per-property sources/conditions and isotropic/orthotropic stability
checks through read-only `material.properties.validate`. It does not assign or
persist properties, provide a solver or enable rendering; see
[the contract and staged follow-ups](docs/physical-material-cards.md).
- Directional elasticity (#994) queries the validated card in material-local axes
through read-only `material.properties.directional`, with source/condition review.
It does not assign body frames, predict structural behavior or enable a solver.
## Library foundations (#785, #786, #787)
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
selected appearance/material revisions into existing design definitions in one undo step.
Appearance offers local loading/search/export; physical materials use existing commands.
This slice is independent of the render stack and does not change the document format.
See [the contract and limits](docs/book/src/dev/local-libraries.md).
- Proposed phases cover offline bundled/project/user packs, physical materials and
appearances, editable wood mapping, and parameterized component/assembly families.
Stable identity/revision, copied design definitions, provenance and resource limits
apply from the first implementation. Existing material, appearance and standard-part
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
for source/license review and acceptance gates. This is planning only: pack loading,
texture rendering and expanded component families are not implemented by this entry.
## Render foundation
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
- Explicit texture normalization (#1218) produces completed float RGBA with
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
gamut clipping only when explicitly requested. It is not a renderer/resource
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
stay unchanged. HDRI, transmission and textures remain separate gates.
See [the consumer and limitations](docs/render-opaque-pbr.md).
- Combined integration is tracked in #842, with independent scalar libraries in
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
Render/library consumers use the merged asynchronous picker (#1127). Material-card
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
results stay outside persistence/undo; native workers and cooperative browser tasks
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
- R3 adds an overlay-free product scene and the integrated Render workspace, with
runtime GPU host capabilities, per-document drafts and named camera/background
controls. Design keeps its camera and drawing policy. Raster transparency remains
approximate; PBR and environments are later slices. See
[workspace and scene contracts](docs/render-workspace.md).
- R2 adds bounded named render setups in document format 21 and the read/save
`render.settings.get/set` commands. Proven render-only changes preserve geometry
and prepared world data through undo/redo and checkpoint restore. This slice does
not add a Render workspace, PBR, environments or image jobs; see
[settings and refresh policy](docs/render-settings.md).
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
- The initial opaque surface numerical contract for #1004 provides bounded linear
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
The numerical module alone advertises no capabilities; the installed consumer above
provides rendering. See
[the model, evidence and remaining consumer gates](docs/render-surface.md).
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
it does not yet enable HDRI/PBR rendering or a document import command. See
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
bounded image/scratch admission checks, exposed by the read-only `render.backends`
command. It describes the current CPU raster snapshot path without evaluating the model.
The legacy Design snapshot route remains unchanged. Later slices above provide the
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
## Dimensioning and error reporting
- Operation length fields share design-unit defaults and input evaluation, including secondary
distances, thin walls, blend controls, sheet/plastic rules, joints and motion keys. Canvas
handles and sketch previews use the same lengths as committed geometry. Existing stored
millimetre values retain their size when edited in a design using another unit (#3271).
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
Both tools retain full replies by default.
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
Polygon and Spline. Each open part remembers its last interactive choice per family
across sketch sessions and document-tab switches; closing the part resets these UI choices.
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
placement, including orientation and the selected side count.
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
dimension values, so line type can change while a shape is in progress.
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
committed geometry; centerlines retain their dash-dot pattern.
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
arguments reject such a name when an older design's construction plane already has it. Use
the other alias, the construction feature's numeric ID or an explicit plane; existing
stored references keep their saved meaning. Section Analysis uses the same check.
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
Interior selection requires every profile boundary to belong to the same text source;
the editor highlights the whole source without replacing profile selections.
Content, baseline X/Y, height and rotation update the original text source in one undo step;
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values.
Dragging a text outline moves the entire source in one undo step; Escape restores the
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
about its baseline; copies keep an independent editable text source.
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
associative axis expressions and construction-axis references remain unsupported.
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
it), drives it like any other dimension and keeps the circle on its side of the line.
- Sketch Dimension takes a point and a circle or arc, or two circles or arcs (#4064): it
measures to the circle's edge (from the point, or between the two edges: the gap, 0 when
they touch, how far they overlap, or the narrowest gap between nested circles).
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
the current design unit; changing units leaves existing geometry and expressions intact.
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
and pocket depths, interference volumes and parts-library lengths also use design units.
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
consumes complete point/curve selections consistently; mixed or ambiguous selections
retain the picking workflow. Rejected constraints preserve selection and undo history.
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
useful reproduction context. The draft is editable, with optional capture of the actual
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
reporting for unexpected results that did not produce an error.
- The header exposes **Report a bug**, including an accessible compact control on
narrow windows. It opens the same editable report and screenshot review without
changing the active model or a pending dimension.
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
journal) leaves a local report with the backtrace, version and commit, the command and the
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
Crash Reports…) with a reviewed issue draft; agents read them through
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
the agent's whole process group ends with the panel or the app. Docks left or right,
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
Grok login. See [agent chat](docs/agent-chat.md).
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
writes a design's history as a script that `eval` and `run` replay into the same design —
user parameters, units, sheet metal and plastic rules and materials as their own commands,
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
script after a step gives the design at that step. Appearances, folders, views and
configurations are listed as left out; components and joints are not supported yet.
## 0.2.0 release batch
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
durable supported profile/face references, endpoint dimensioning and contextual reporting.
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
authored splits and actual creases remain protected. Wireframe retains physical edges.
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
prisms with separated circular holes; unsupported shapes retain the existing strict check.
- Common-bore Union retries check additional boundary curves before recutting the bore,
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
web app and both Flatpak architectures were verified. The replacement README demo uses
the original electronics enclosure story and animated pointer in the actual English
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
## 0.2.1 critical fixes
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
extrusion previews build only their tools; confirmation validates the final Boolean.
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
computes on a scratch session in the background, with atomic acceptance and cancellation.
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
- Imported rotated face groups can check all full start-face seams in one guarded
construction. Separated tools may batch a Join when each meets the same single body;
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
cover all four extrusion operations.
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
Safe interior classification handles roundoff at shared imported line/arc endpoints.
Straight extrusion feedback caches existing face/profile triangles and updates the display
directly, including a newly opened STEP's single identity component placement; other
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
Confirmation and tool previews use main's shared background calculation. Main's collapsed
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs.
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
certificates. Sketches placed directly on the enclosure wall and inner floor follow
width and wall changes, including after the USB cut and cold reopening.
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
face, with exact full-height supports and boundary stitching. Preserve authored face
splits and face appearances. Permanent regressions cover material, STEP, reopening and
unaffected bodies across the extrusion end plane.
- Feature dialogs use the available screen height for their fields, with OK and Cancel
outside the scrolling region, including enlarged display scales.
- Design format 13 records shell-face attachments. Older designs remain readable;
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
attachment rather than guessing a plane.
- Saved projections from a removed strip retain their geometry and report the lost
reference. Explicitly reselect the wall to repair that projection.
## Workspace layouts (#2029)
- General preferences save Floating or Compact using the universal UI containers.
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
flush beneath them with a square join, and opens tool sheets below the compass.
Sheets remain draggable, rounding all corners away from their right dock; footer
backgrounds follow the same corners. A separate preference hides ribbon icon labels
for a shorter ribbon in either layout.
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
retains the active tool, camera, selection and design; system windows and menus
keep their existing behavior.
## View navigation
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
its layout stable during a drag and applies the selected scale on release.
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
## Known limitations
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
patches require separate upstream review because advisory databases cannot version-match
their path dependencies. Pull requests retain the existing licence checks.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
multi-face ends and corner junctions remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
rounds preserve the ring's radii and the enclosed planar face (#1557);
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
straight edges (convex or concave) between planar faces with perpendicular
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
curved chamfer chains with straight planar ends can terminate across two existing planar
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
checked within 0.1% of analytic offset slices), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
with the edge between them too, the corner becomes a sphere octant),
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
between two overlapping extruded arcs); knife edges, where the two faces have opposite
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
other, except a round after a round of the edge between the wall and a flat face that it
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
curves is refused as too large rather than returning an unmeshable face (#1113).
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
curved wall qualification rejects false transverse axes instead of filling holes.
Bodies touching along one whole face
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
their neighbours are planes or cylinders. Other coincident curved configurations can still
fail. Bounded containment classification preserves fully internal cavities and nested
material islands independently of shell order, with STEP round-trip and material-membership
regressions. Near-contact curved shells use the general boolean path rather than a
containment shortcut. Spheres are built from six pole-free patches.
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
references resolve by name first, follow splits and merges, and warn when the entity split,
merged or vanished, or was found by geometry only (operations that report no history yet).
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
source certificates with checked target and author occurrence paths. A missing or changed
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
retaining current geometry and dimension identities. Unsupported modified faces need an
explicit fixed plane. Legacy attachments retain their original resolver behavior.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
walls through denser stations where the path has corners or the profile has holes.
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
turns the profile and scales it (or stretches it across to the rail) against the start; two
rails carry it between them. Rails along a path with sharp corners are refused for now.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
speeds without changing stored nodes or relaxing the distance tolerance (#971).
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
editable source updates the derived boundary live. Break Association restores point editing.
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
Newly captured adaptive profile references with complete source coverage retain their outer
and hole identities through span-count changes, including parameter edits after extrusion.
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
reselection. Format 12 stores the new bounded whole-boundary references.
Projected analytic boundaries follow nested loop identities through resizing, including
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
can lose or regain corner arcs during an inset while preserving reserved curve identities
and linked profile selections. Format 12 saves those associations; compatible older closed
offsets upgrade structurally without changing their curve IDs. Breaking an association
freezes its current boundary and materializes downstream profile references. Child offsets
of a boundary with retired arcs still require breaking its association first; mixed or
nonconvex junctions and retained references to disappearing individual arcs remain limited.
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
use the sketch's actual target, author or uniquely placed third-component frame.
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
use the sketch-normal direction for each selected profile, including To Object and curved
target trimming, independently of the selected axis sign.
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
radial walls. New features infer a unique eligible cylinder when the face field is empty;
ambiguous targets require an explicit face. Saved target
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
between sections, and sections with holes cannot collapse to a point.
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
the origin; clicked UI regressions verify geometry, closed profiles and undo.
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
invalidate the display cache without changing CAD geometry or timeline history. Work
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
The Sketch Palette scrolls its options on short windows and enlarged displays while
keeping constraint status and Finish Sketch visible.
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
existing geometry before assigning positions, retaining saved curve IDs and constraints
across enumeration changes and resize, including concentric circles. Ambiguous identities
retain the last geometry and report a lost reference; permutation matching is bounded.
- Project accepts complete supported preselection in one undo step. Visible external sketch
curves and points retain their source sketch identity; model geometry, axes and planes use
the existing linked projection commands. Rejection preserves the document and selection;
mixed selections retain ordinary tool-first picking.
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
selecting visited values for replacement. Expression completion retains forward-Tab
priority; real keyboard regressions cover rectangles, lines and single-value circles.
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
rejected values keep the editable field focused.
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
checked occurrence paths. Active nested occurrences author planar sketches and solids in
their selected frame. Pending moves compose through parents; dialog cancellation restores
its selected move. Source materials and body/face appearances follow transformed instances.
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
independent feature/input histories, styles and internal motion records, with bounded geometry
checks and explicit external-link notices. Paste New selects the copy while preserving the active
authoring context. Imported periodic STEP seams survive placement and reexport with shared
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
record their coordinate frame; legacy wires migrate from known provenance without changing
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
See [occurrence workflows](docs/occurrence-workflows.md).
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
button drops keep insert-before ordering.
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
Parameters opens the user/model variable editor; Configurations opens the configuration table.
Configure remains available in the panel dropdown.
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
- Movable window title bars follow the app's dark/light header palette. Radial right-click
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
- Desktop distribution: running Windows windows and executable resources use the app icon;
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
callbacks to prevent black frames. Other backends retain the requested sample count, and
stored preferences remain unchanged.
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
restrict values. Preview explains effective values without modifying the document; applying
rules validates rows atomically. Length expressions retain their authoring units across
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
Component styles and certified semantic face styles are available; unsupported lineage refuses
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
sampling.
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
exact holes and retained islands, including the union of multiple selected profiles, and
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
tapered or bend-crossing machining refuses atomically instead of restoring material later.
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
clear of the flange beside it, and the returns of one feature are mitred toward each other
with the rule's gap where their flanges meet at a corner; returns of separate features, and
flanges on returns, are not closed toward each other.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
check. Under-resolved fits return an error.
- Contact driving checks the trajectory between clear endpoints, including linked joints,
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## Assembly dragging
The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels).
## 3D sketch bridges
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Construction on 3D paths
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Design properties (#1015)
- Designs carry project, part number, revision, author, organization, description and licence
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
Per-component properties (a part number per component) can follow with the same type.
## Document-opening performance
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
to eight workers, retaining serial traversal, result order and persistent source identities.
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
import times; single-solid assembly parts are not scheduled across nodes. See
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
format. Generated-mesh storage measurements reduce serialization and decoding work;
a seekable reader defers original blobs until after provisional display.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
and geometry records, with a separate truck adapter and bounded binary reader. Exact
face-source certificates and operation history survive codec round trips. A provisional
picture can be generated before original blobs are read; editing and measurement wait
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
the same document despite concurrent file writes. Default saves omit this cache and never force cold
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
Keys, resource budgets and document/revision guards reject incompatible, hostile or
stale results. A second modelling kernel still requires its own tested adapter.
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
Home reads it without rebuilding the design when its local picture is missing;
mismatched or malformed thumbnails fall back to normal generation. The image is
kernel-independent and saving never forces cold meshing to produce it.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
on a local case assembly; shape measurements and topology match the original build.
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
- Prepared assembly geometry survives selection and view changes while real geometry and
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
## Kernel
- Successful Boolean retries report their strategy and numerical adjustments as
feature warnings and structured inspection data. Diagnostics survive cached
evaluation, undo/redo and cold reopening; the robustness runner counts retry
events by strategy and reports any bounded-log truncation. Existing geometry
acceptance checks remain in force.
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
on such a pair, both faces are split along the line of their cylinder through the touching
points (the solids are unchanged), the boolean is taken of those, and its history is composed
back over the operands as given (#788). The result keeps the split lines as edges of the bore
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
in a 45 mm block with a tool of 80 mm) is still refused.
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
polylines without panicking. Direct constructor regressions retain valid open endpoints
and closed spherical intersections; the original interactive trigger remains unidentified.
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
cover stepped stock, STEP round trips and saved projections from removed pieces.
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
The short version, for people who use SolidCraft. The detailed developer status (milestone
tables, limitations, measurements) is in [docs/roadmap-detail.md](docs/roadmap-detail.md).
## What SolidCraft is
SolidCraft is open-source parametric 3D CAD written in Rust. You sketch, constrain, extrude, and
change a parameter to watch the timeline rebuild. It runs as a desktop app and in the browser
([try it](https://solidcraft.io/app)), and it has a command-line tool and an MCP server so that
scripts and AI agents can drive the same commands you use.
## Works today
- Sketching with a constraint solver, and the usual create and modify tools.
- Extrude, revolve, loft, sweep, coil, fillet, chamfer, shell, draft, holes, threads,
patterns, mirror and split.
- Parameters with units and expressions, and configurations.
- Faces and edges keep their names through edits, so references survive changes.
- Components, assemblies, joints, motion studies and exploded views.
- Sheet metal with flat patterns and DXF export, and a plastic-enclosure subset.
- A Render workspace with GPU rendering, studio lighting, materials and image export
(first release in 0.22.0).
- Import and export of STEP, IGES, 3MF, STL, OBJ and DXF, and FreeCAD and Rhino files.
- Autosave and crash recovery, and the browser build of the same app.
The full list of commands is in [docs/features.md](docs/features.md). Release notes for each
version are on the [releases page](https://github.com/tangency-labs/solidcraft/releases).
## In progress now
Each feature below is a [milestone](https://github.com/tangency-labs/solidcraft/milestones) holding
the issues for it, so the count shows how far along it is. Counts are as of 2026-10-25, one per
feature or request. Work merged on a feature branch (`drawings`, `render`) counts as built but
reaches the app only when that branch merges. They are rough, not a promise.
- **[Rendering & materials](https://github.com/tangency-labs/solidcraft/milestone/17)**: in progress, 9 of 34 pieces done, 3 of them built only on the `render` branch and in the app when it merges. The basic Render workspace is already in releases (0.22.0); the interface rework (#6281) and the material and light libraries are what is left.
- **[Drawings](https://github.com/tangency-labs/solidcraft/milestone/8)**: in progress, 3 of 21 pieces built (2 in releases, 1 on the `drawings` branch), and 3 more partly built there (sheets and borders, bill-of-materials types, the paper scene). Not in the app yet: it lands when the `drawings` branch merges. Today you can export the current view as a DXF.
- **[Sheet metal](https://github.com/tangency-labs/solidcraft/milestone/15)**: in progress, 1 of 30 requests done. Flanges, hems, unfold and flat patterns already work; the 30 are extensions, mostly the per-edge flange options (segments, miters, partial flanges, reliefs) and bend allowance.
- **[Sketching](https://github.com/tangency-labs/solidcraft/milestone/14)**: in progress, 12 of 50 pieces done. Dimension types, selection and snapping options.
- **[Solid modeling](https://github.com/tangency-labs/solidcraft/milestone/2)**: in progress, 9 of 50 pieces done. Blends, holes, threads and patterns.
- **[Assemblies & joints](https://github.com/tangency-labs/solidcraft/milestone/3)**: in progress, 4 of 19 pieces done. Joints, motion studies and exploded views already work; placement rules, linked sub-assemblies, fastener stacks and weldment corners are the pieces left.
- **[Import & export](https://github.com/tangency-labs/solidcraft/milestone/5)**: in progress, 7 of 39 pieces done. FreeCAD, SolidWorks and point-cloud import, and mesh export options.
- **[AI agents & MCP](https://github.com/tangency-labs/solidcraft/milestone/18)**: in progress, 2 of 8 pieces done. Agent chat and the MCP server.
- **[Interface, web app & plugins](https://github.com/tangency-labs/solidcraft/milestone/4)**: in progress, 31 of 96 pieces done. Ribbon redesign, navigation, themes, the web app and plugins.
- **[SMLib kernel migration](https://github.com/tangency-labs/solidcraft/milestone/1)**: in progress, 1 tracking issue open. The kernel is moving from truck to NVIDIA's SMLib (usd-brep, Apache-2.0) on the `kernel-smlib` branch; it is not in releases yet. Tracked in [#4012](https://github.com/tangency-labs/solidcraft/issues/4012).
## Next
- **[Surfacing](https://github.com/tangency-labs/solidcraft/milestone/16)**: next, 6 of 14 pieces done. Patch, trim, blend and match exist; the rest of the surface toolset is planned.
- **[Manufacturing / CAM](https://github.com/tangency-labs/solidcraft/milestone/9)**: next, 1 pieces tracked, none done. There is no CAM in SolidCraft yet. Send to Slicer and a first toolpath slice are in review; proposals are [#1153](https://github.com/tangency-labs/solidcraft/issues/1153) and [#1621](https://github.com/tangency-labs/solidcraft/issues/1621).
Features are not tied to a release or a date. Work lands in the daily releases as it is finished.
Anything without a milestone, such as bug fixes, is not a feature and is not listed here.
## How releases work
- Releases ship daily, as numbered versions (v0.22.0, v0.22.1) with nightly pre-releases in
between. They are delivered through the in-app updater.
- SolidCraft tells you when a newer stable release exists and never updates without asking.
Where it can replace itself safely (AppImage, Windows installer and portable builds) there is
an **Update now** button; downloads are checked against the release's SHA-256 list. See
[updates and privacy](docs/book/src/guide/updates.md).
- Design files carry a format number. A new version opens designs from older versions, but an
older version refuses a design saved by a newer one (0.22.0 writes format 35, and 0.21.0 and
earlier will not open it). Each release's notes say when the format changed. Keep a copy of
an important design before you move to a new version.
## Licence
SolidCraft is licensed MIT OR Apache-2.0, at your option; see [LICENSE-MIT](LICENSE-MIT) and
[LICENSE-APACHE](LICENSE-APACHE). Versions already released stay available under those licences
permanently.
## How to follow along
- Feature milestones: <https://github.com/tangency-labs/solidcraft/milestones>
- Issues: <https://github.com/tangency-labs/solidcraft/issues> (area labels such as
`area:kernel`, `area:sketch` and `area:ui` group them)
- Discord: <https://discord.gg/FtU4NQNVDH>
- Releases: <https://github.com/tangency-labs/solidcraft/releases>
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
+57 -7
View File
@@ -114,6 +114,46 @@ pub fn opens_design(extensions: &[&str]) -> bool {
extensions.is_empty() || extensions.iter().any(|e| solidcraft_engine::io::DESIGN_EXTS.contains(e))
}
/// Where an Open for `extensions` (what the command reads; `&[]`: anything) is answered.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum OpenRoute {
/// The browser's open dialog for a design: the file keeps its handle, so Save writes back.
Disk,
/// The "Files in this browser" window, where the browser can't open a design from the disk.
Panel,
/// A plain file input that offers these types: the file is read into the engine's files and
/// the command runs on it (Insert DXF or SVG, images, meshes, imported settings).
Upload,
}
/// Choose how an Open for `extensions` asks for its file; `api`: the browser can open a file
/// from the disk and keep its handle.
pub fn open_route(api: bool, extensions: &[&str]) -> OpenRoute {
match (opens_design(extensions), api) {
(true, true) => OpenRoute::Disk,
(true, false) => OpenRoute::Panel,
(false, _) => OpenRoute::Upload,
}
}
/// The `accept` list of a file input for `extensions` (with or without their dot): ".dxf,.svg".
pub fn accept_list(extensions: &[&str]) -> String {
let list: Vec<String> = extensions
.iter()
.map(|e| e.trim().trim_start_matches('.'))
.filter(|e| !e.is_empty())
.map(|e| format!(".{}", e.to_ascii_lowercase()))
.collect();
list.join(",")
}
/// The largest file an upload for `extensions` reads: images go into a canvas, which has a size
/// limit, so a bigger one is refused before it is read. `None`: the importer checks the file.
pub fn upload_limit(extensions: &[&str]) -> Option<usize> {
let image = |e: &&str| ["png", "jpg", "jpeg"].contains(&e.trim_start_matches('.').to_ascii_lowercase().as_str());
(!extensions.is_empty() && extensions.iter().all(image)).then_some(solidcraft_engine::doc::canvas::MAX_CANVAS_BYTES)
}
/// The file type a Save As dialog offers for `name`: (description, MIME type, extension).
pub fn save_type(name: &str) -> (&'static str, &'static str, &'static str) {
let json = std::path::Path::new(name).extension().is_some_and(|e| e.eq_ignore_ascii_case("json"));
@@ -140,7 +180,7 @@ mod web {
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemFileHandle, IdbDatabase, IdbObjectStore, IdbOpenDbRequest, IdbRequest, IdbTransactionMode};
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, opens_design, parse, path, plan_prune, save_type};
use super::{HANDLE_MAX_AGE_MS, is_abort, make_id, parse, path, plan_prune, save_type};
use crate::files::{Files, err, read_handle};
const DB: &str = "solidcraft-disk-handles";
@@ -294,7 +334,7 @@ mod web {
let options = Object::new();
let _ = Reflect::set(&options, &"multiple".into(), &JsValue::FALSE);
let extensions = Array::new();
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm"] {
for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm", ".dxf", ".svg"] {
extensions.push(&e.into());
}
let accept = Object::new();
@@ -315,11 +355,6 @@ mod web {
p
}
/// Does an Open for `extensions` use the browser's open dialog?
pub fn handles_open(extensions: &[&str]) -> bool {
supported() && opens_design(extensions)
}
// ---- writing ------------------------------------------------------------------------
/// Queue what a command wrote to a disk path. Writes go one at a time; a newer write of the
@@ -621,6 +656,21 @@ mod tests {
assert!(!opens_design(&["json"]));
}
/// Insert DXF or SVG, images and other non-design files get a file input that offers their
/// types in every browser (#2052); designs keep the disk dialog or the browser's files.
#[test]
fn opens_of_other_files_use_a_file_input_for_their_types() {
assert_eq!(open_route(true, &["dxf"]), OpenRoute::Upload);
assert_eq!(open_route(false, &["png", "jpg", "jpeg"]), OpenRoute::Upload);
assert_eq!(open_route(true, &[]), OpenRoute::Disk);
assert_eq!(open_route(false, &["solidcraft"]), OpenRoute::Panel);
assert_eq!(accept_list(&["dxf"]), ".dxf");
assert_eq!(accept_list(&["png", ".JPG", "", "jpeg"]), ".png,.jpg,.jpeg");
assert_eq!(accept_list(&[]), "");
assert!(upload_limit(&["png", "jpg", "jpeg"]).is_some());
assert_eq!(upload_limit(&["dxf"]), None);
}
#[test]
fn the_save_dialog_offers_the_type_of_the_name() {
assert_eq!(save_type("Plate.solidcraft").2, ".solidcraft");
+64 -2
View File
@@ -500,8 +500,9 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
input.set_type("file");
// Everything the browser build imports, from the format registry: cadmpeg is pure Rust, so
// FreeCAD and Rhino files are part of it.
let accept = solidcraft_engine::io::formats::open_extensions(true).iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
// FreeCAD and Rhino files are part of it; plus the DXF and SVG drawings Open and Insert take.
let accept =
solidcraft_engine::io::formats::open_extensions(true).iter().chain(&["dxf", "svg"]).map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
input.set_accept(&accept);
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
let _ = input.set_attribute("style", "display:none");
@@ -527,6 +528,67 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
input.click();
}
/// Ask for one file of these types (`extensions`, e.g. `["dxf"]`) with a plain file input, the
/// dialog every browser has. The answer is the file's path in the engine's files (under
/// [`UPLOAD`], as for a file picked in the disk dialog), so the command that asked runs on it;
/// `None` when the dialog was closed or the file could not be read.
pub fn pick_upload(files: &Rc<Files>, extensions: &[&str]) -> impl std::future::Future<Output = Option<String>> + use<> {
// Shown now, in the click: a file dialog needs the user's gesture.
let chosen = choose_file(&disk::accept_list(extensions));
let limit = disk::upload_limit(extensions);
let files = files.clone();
async move {
let read = async {
let value = JsFuture::from(chosen?).await.map_err(err)?;
// Closing the dialog answers no file: nothing to say.
let Ok(file) = value.dyn_into::<web_sys::File>() else { return Ok(None) };
let name = file.name();
if limit.is_some_and(|max| !file.size().is_finite() || file.size() > max as f64) {
return Err(format!("{name}: the file is too large"));
}
let bytes = blob_bytes(&file).await.map_err(|e| format!("{name}: {e}"))?;
let path = format!("{UPLOAD}{name}");
vfs::insert(&path, bytes);
Ok(Some(path))
};
match read.await {
Ok(path) => path,
Err(e) => {
files.say(format!("open: {e}"), true);
None
}
}
}
}
/// Show a hidden `<input type=file>` offering `accept` (call in the click). The promise answers
/// the picked `File`, or `null` when the dialog is closed (the input's `cancel` event).
fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
let doc = web_sys::window().and_then(|w| w.document()).ok_or("this page can't show a file dialog")?;
let input = doc.create_element("input").map_err(err)?.dyn_into::<web_sys::HtmlInputElement>().map_err(|_| "no file input".to_string())?;
input.set_type("file");
input.set_accept(accept);
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
input.set_attribute("style", "display:none").map_err(err)?;
doc.body().ok_or("this page has no body")?.append_child(&input).map_err(err)?;
let promise = js_sys::Promise::new(&mut |resolve: Function, _reject: Function| {
// One answer for a pick or a cancel; read from the event, so it does not keep the input.
let done = Closure::once_into_js(move |event: web_sys::Event| {
let file = event.target().and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok()).and_then(|input| {
input.set_onchange(None);
input.set_oncancel(None);
input.remove();
input.files().and_then(|list| list.get(0))
});
let _ = resolve.call1(&JsValue::UNDEFINED, &file.map_or(JsValue::NULL, JsValue::from));
});
input.set_onchange(Some(done.unchecked_ref()));
input.set_oncancel(Some(done.unchecked_ref()));
});
input.click();
Ok(promise)
}
/// Read files dropped on the page.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
for f in std::mem::take(&mut raw.dropped_files) {
+12 -9
View File
@@ -330,7 +330,7 @@ mod web {
files::pick_from_disk(&self.files, Intent::Open);
}
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
.on_hover_text("STEP, 3MF or STL into the open design")
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
.clicked()
{
files::pick_from_disk(&self.files, Intent::Insert);
@@ -383,7 +383,7 @@ mod web {
let design = solidcraft_engine::io::is_design_path(&e.name);
if !design
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
.on_hover_text("Into the open design (STEP, 3MF, STL)")
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
.clicked()
{
act = Some(("insert", e.name.clone()));
@@ -707,14 +707,17 @@ mod web {
let (restorable, restorer) = (files.clone(), files.clone());
let services = if launch.embed { Services::default() } else { Services {
// Open shows the browser's open dialog for a design where it has one
// (the file keeps its handle, so Save writes back to it); otherwise,
// and for inserting STEP or meshes, the browser's files window.
pick_open: Some(Box::new(move |exts: &[&str]| {
if disk::handles_open(exts) {
return Box::pin(disk::pick_open(&opener));
// (the file keeps its handle, so Save writes back to it), else the
// browser's files window. A command that reads another kind of file
// (Insert DXF or SVG, an image, a mesh, settings) gets a file dialog
// offering that kind, and runs on the picked file.
pick_open: Some(Box::new(move |exts: &[&str]| match disk::open_route(disk::supported(), exts) {
disk::OpenRoute::Disk => Box::pin(disk::pick_open(&opener)),
disk::OpenRoute::Upload => Box::pin(files::pick_upload(&opener, exts)),
disk::OpenRoute::Panel => {
panel.panel.set(true);
Box::pin(std::future::ready(None))
}
panel.panel.set(true);
Box::pin(std::future::ready(None))
})),
// A design is saved to a file the user picks, or kept in the browser
// where the browser can't pick one; everything else is downloaded.
+54 -9
View File
@@ -292,7 +292,9 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
dialog = dialog.add_filter(label, &case_variants(first));
}
// The groups come from the format registry, so a new format joins this dialog by itself.
dialog = dialog.add_filter("Designs, CAD files and meshes", &case_variants(&io::formats::open_extensions(true)));
let mut everything = io::formats::open_extensions(true);
everything.extend(["dxf", "svg"]);
dialog = dialog.add_filter(solidcraft_ui_egui::i18n::tr("file.open.drawing_filter"), &case_variants(&everything));
for (label, exts) in io::formats::import_filters(true) {
dialog = dialog.add_filter(label, &case_variants(&exts));
}
@@ -576,13 +578,22 @@ fn scene_samples(backend: eframe::wgpu::Backend, samples: u32, supported: &[u32]
}
}
/// Anti-aliasing samples from Preferences (4 when unset).
fn msaa() -> u16 {
/// Keep the default until the installed renderer's startup check proves a fallback is needed.
fn scene_msaa_request(saved: Option<u16>) -> u16 {
saved.unwrap_or(MSAA)
}
/// Query the scene's colour/depth intersection, rather than the depthless window's counts.
fn scene_sample_choices(depth_bits: u8, stencil_bits: u8, query: impl FnOnce(Option<eframe::wgpu::TextureFormat>) -> Vec<u32>) -> Vec<u32> {
query(eframe::egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits))
}
/// An explicitly saved anti-aliasing choice, absent until Graphics changes it.
fn msaa() -> Option<u16> {
graphics()
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
.and_then(|m| u16::try_from(m).ok())
.filter(|m| [1, 2, 4, 8].contains(m))
.unwrap_or(MSAA)
}
/// Windows and Linux: no OS title bar; the application bar is the title bar
@@ -686,7 +697,7 @@ 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 samples = if safe_mode { MSAA } else { msaa() };
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 };
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
@@ -816,18 +827,24 @@ fn main() -> eframe::Result {
(info.name.clone(), format!("{} ({:?}, {:?})", info.name, info.device_type, info.backend))
})
.collect();
let scene_samples = scene_samples(rs.adapter.get_info().backend, u32::from(samples), &rs.msaa_supported);
let info = rs.adapter.get_info();
let samples = scene_msaa_request(saved_samples);
let supported = scene_sample_choices(depth_bits, STENCIL_BITS, |depth| {
eframe::egui_wgpu::supported_msaa_samples(&rs.adapter, &rs.device, rs.target_format, depth)
});
let scene_samples = scene_samples(info.backend, u32::from(samples), &supported);
eprintln!("SolidCraft graphics: {:?}, MSAA {scene_samples}x (requested {}x)", rs.adapter.get_info().backend, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
let scene_samples = app.set_wgpu_split(rs, depth_bits, STENCIL_BITS, scene_samples);
app.msaa_supported = supported;
app.preferences.msaa = u32::from(samples);
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
// The graphics driver is loaded now; it may have put its own exception filter
// in front of ours.
#[cfg(target_os = "windows")]
native_crash::reinstall();
// A saved anti-aliasing the device can't do: back to the default, with a notice.
// Reflect a confirmed probe fallback or an unsupported saved count in Graphics.
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
let info = rs.adapter.get_info();
solidcraft_engine::crash::set_environment(&format!(
"Renderer: {:?} {} ({}), MSAA {scene_samples}x",
info.backend, info.name, info.driver
@@ -1200,6 +1217,34 @@ mod msaa_tests {
}
}
#[test]
fn scene_sample_choices_include_the_scene_depth_format() {
use eframe::wgpu::TextureFormat;
for (bits, expected) in [(24, TextureFormat::Depth24PlusStencil8), (32, TextureFormat::Depth32FloatStencil8)] {
// A synthetic colour target supports 8x, but the scene depth supports only 1x/4x.
let supported = super::scene_sample_choices(bits, 8, |depth| match depth {
Some(format) if format == expected => vec![1, 4],
None => vec![1, 4, 8],
other => panic!("unexpected scene depth: {other:?}"),
});
assert_eq!(supported, [1, 4]);
assert_eq!(super::scene_samples(eframe::wgpu::Backend::Dx12, 8, &supported), 4);
}
}
#[test]
fn scene_msaa_keeps_four_samples_by_default_and_explicit_choices() {
use eframe::wgpu::Backend;
let default = super::scene_msaa_request(None);
assert_eq!(default, 4);
for backend in [Backend::Dx12, Backend::Vulkan, Backend::Metal] {
assert_eq!(super::scene_samples(backend, u32::from(default), &[1, 4]), 4);
}
for saved in [1, 2, 4, 8] {
assert_eq!(super::scene_msaa_request(Some(saved)), saved);
}
}
#[test]
fn the_scene_follows_the_backend_policy_too() {
use eframe::wgpu::Backend;
+32
View File
@@ -48,6 +48,38 @@ source = "#236ead"
light = "#236ead"
dark = "#2976b8"
# Simultaneous component colours for inspection icons; existing workspace ramps stay unchanged.
[roles.component-orange-1]
source = "#f1d2af"
light = "#f1d2af"
dark = "#ba8f6a"
[roles.component-orange-2]
source = "#c06f00"
light = "#c06f00"
dark = "#b86f04"
[roles.component-orange-3]
source = "#a45000"
light = "#a45000"
dark = "#95682d"
[roles.component-yellow-1]
source = "#e5d99b"
light = "#e5d99b"
dark = "#a9985f"
[roles.component-yellow-2]
source = "#a28200"
light = "#a28200"
dark = "#9a8100"
[roles.component-yellow-3]
source = "#826700"
light = "#826700"
dark = "#857100"
[strokes] # on the 64 grid
geometry = 2.55
action = 4.25
@@ -57,6 +57,8 @@ circle_tt false
circle_ttt false
close false
coil false
colored_components false
colored_components_off false
combine false
compare false
component false
@@ -245,6 +247,7 @@ status_error false
status_recovery false
style false
suppress false
surface false
surface_blend false
surface_boundary false
surface_extend false
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Colored components</title>
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#c06f00"/>
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#a45000"/>
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#f1d2af"/>
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#a28200"/>
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#826700"/>
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#e5d99b"/>
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Disable component colors</title>
<path d="M9.235 17.706L15.059 20.882L15.059 48.941L9.235 44.926Z" fill="#328bd3"/>
<path d="M35.176 9.235L15.059 20.882L15.059 48.941L36.765 35.139Z" fill="#236ead"/>
<path d="M29.046 5L35.176 8.176L15.196 19.718L9.066 16.541Z" fill="#b9ddf8"/>
<path d="M29.046 5.529L35.176 8.706L35.176 36.235L15.059 49.471L9.235 45.765L9.066 17.071Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.066 17.6L15.059 20.882L34.647 9.235" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.059 20.882L15.059 47.067" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L44.949 51.122L32.222 43.774Z" fill="#328bd3"/>
<path d="M54.934 20.173L44.949 25.944L44.949 51.122L54.934 45.352Z" fill="#236ead"/>
<path d="M42.059 12.412L54.934 20.173L44.949 25.944L32 17.706Z" fill="#b9ddf8"/>
<path d="M41.159 11.766L54.934 20.173L54.934 45.352L44.949 51.122L31.164 42.715L31.164 17.536Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.222 18.595L44.949 25.944L54.934 20.173" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M44.949 25.944L44.949 51.122" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L31.015 59L18.299 51.652Z" fill="#328bd3"/>
<path d="M41.921 40.121L31.015 46.411L31.015 59L41.921 52.711Z" fill="#236ead"/>
<path d="M29.194 32.773L41.921 40.121L31.015 46.411L18.299 39.062Z" fill="#b9ddf8"/>
<path d="M29.194 32.773L41.921 40.121L41.921 52.711L31.015 59L18.299 51.652L18.299 39.062Z" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.299 39.062L31.015 46.411L41.921 40.121" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.015 46.411L31.015 59" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+2
View File
@@ -2031,6 +2031,7 @@
"ui.one_side": "Einseitig",
"ui.open": "Öffnen",
"ui.open_ends_tip": "Berührt nichts",
"ui.open_linked_design": "Verknüpftes Design öffnen",
"ui.open_preferences": "Einstellungen öffnen",
"ui.open_recent": "Zuletzt verwendet",
"ui.operation": "Operation",
@@ -2233,6 +2234,7 @@
"ui.up_axis_note": "Welche Achse in den Ansichten nach oben zeigt (Achsenansicht, Home, Ansehen). Designs bleiben unverändert.",
"ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren",
"ui.view_centre": "Ansichtszentrum",
"ui.viewport_theme_follow": "Dem Design folgen",
"ui.visual_shaded_hidden_edges": "Schattiert mit verdeckten Kanten",
"ui.visual_shaded_with_edges": "Schattiert mit Kanten",
"ui.visual_style": "Ansichtsstil",
+12
View File
@@ -692,6 +692,7 @@
"cmd.component_capture_position": "Capture Position",
"cmd.component_copies": "Find Identical Copies",
"cmd.component_create": "New Component",
"cmd.component_cycle_color": "Cycle Component Color",
"cmd.component_delete": "Delete Component",
"cmd.component_extract": "Extract to File",
"cmd.component_from_bodies": "Create Components from Bodies",
@@ -776,6 +777,8 @@
"cmd.inspect_accessibility": "Accessibility Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_clearance": "Clearance",
"cmd.inspect_colored_components": "Enable Component Colors",
"cmd.inspect_colored_components_disable": "Disable Component Colors",
"cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_continuity": "Continuity Analysis",
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
@@ -1133,6 +1136,7 @@
"dxf.progress.write": "Writing DXF (5/5)",
"dxf.tolerance": "Curve tolerance",
"dxf.tolerance_help": "Visible bodies and shown sketches only. Curves use straight segments. Smaller tolerance gives finer curves and uses more time and memory.",
"file.open.drawing_filter": "Designs, CAD files, meshes and drawings",
"file_load.built": "Built so far: {built} of {total} features.",
"file_load.converting": "Converting solid {done} of {total}",
"file_load.inserting": "Inserting geometry",
@@ -1371,6 +1375,7 @@
"pick the path": "pick the path",
"prefs.add_item": "Add the item",
"prefs.alt_frees_manipulators": "Alt / Option also frees manipulator arrows",
"prefs.anti_aliasing_amd_dx12": "Anti-aliasing uses 1× because the startup check drew nothing with multisampling and passed at 1×. You can choose another setting above; restart to apply it if the check passes.",
"prefs.anti_aliasing_note": "Smooths jagged edges.",
"prefs.applies_on_restart": "Applies on restart",
"prefs.assistant": "Assistant",
@@ -2386,6 +2391,7 @@
"ui.agent_chat_mode_all": "All",
"ui.agent_chat_mode_ask": "Ask",
"ui.agent_chat_mode_edits": "Edits",
"ui.agent_chat_model_no_tools": "{model} can't use SolidCraft's tools, so it can only chat. Pick a model with tool support.",
"ui.agent_chat_model_tip": "The model the agent uses. Your choice is remembered for this agent.",
"ui.agent_chat_new": "New chat",
"ui.agent_chat_new_confirm": "Start a new chat? The current one is cleared.",
@@ -2394,6 +2400,7 @@
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
"ui.agent_chat_no_images": "This agent doesn't take pictures",
"ui.agent_chat_no_picture": "There is no picture on the clipboard",
"ui.agent_chat_no_tool_call": "The model didn't use SolidCraft's tools, so the design wasn't changed. Choose a model that supports tool calling; see Local models in the agent chat guide.",
"ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:",
"ui.agent_chat_none_found_short": "No agent found",
"ui.agent_chat_not_installed": "Not installed",
@@ -2894,6 +2901,7 @@
"ui.one_side": "One Side",
"ui.open": "Open",
"ui.open_ends_tip": "Touches nothing",
"ui.open_linked_design": "Open Linked Design",
"ui.open_preferences": "Open Preferences",
"ui.open_recent": "Open Recent",
"ui.operation": "Operation",
@@ -3094,6 +3102,7 @@
"ui.total_angle": "Total angle",
"ui.trackpad_pan_tip": "Two-finger scroll pans; in Fusion, Shift + two fingers orbits. Off: scroll zooms. Mouse wheel and pinch zoom; thumb wheel pans sideways.",
"ui.trim": "Trim",
"ui.turn_up_axis": "Turn Up Axis",
"ui.twist": "Twist",
"ui.two_finger_scroll_pans": "Two-finger scroll pans",
"ui.two_sides": "Two Sides",
@@ -3111,6 +3120,7 @@
"ui.updateToCurrentView": "Update to Current View",
"ui.vertical_constraint": "Vertical Constraint",
"ui.view_centre": "View centre",
"ui.viewport_theme_follow": "Follow theme",
"ui.visual_shaded_hidden_edges": "Shaded with hidden edges",
"ui.visual_shaded_with_edges": "Shaded with edges",
"ui.visual_style": "Visual style",
@@ -3175,6 +3185,8 @@
"update.up_to_date": "SolidCraft is up to date.",
"update.update_now": "Update now",
"update.you_have": "You have {version}.",
"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",
"web.open_link": "Open linked design?",
"web.open_link_confirm": "There are unsaved changes. Open the linked design in a new tab?",
+12
View File
@@ -692,6 +692,7 @@
"cmd.component_capture_position": "位置をキャプチャ",
"cmd.component_copies": "同一のコピーを検索",
"cmd.component_create": "新規コンポーネント",
"cmd.component_cycle_color": "コンポーネントの色を順に変更",
"cmd.component_delete": "コンポーネントを削除",
"cmd.component_extract": "ファイルに抽出",
"cmd.component_from_bodies": "ボディからコンポーネントを作成",
@@ -776,6 +777,8 @@
"cmd.inspect_accessibility": "アクセシビリティ解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_clearance": "クリアランス",
"cmd.inspect_colored_components": "コンポーネントの色分けを有効化",
"cmd.inspect_colored_components_disable": "コンポーネントの色分けを無効化",
"cmd.inspect_compare": "ボディを比較",
"cmd.inspect_continuity": "連続性解析",
"cmd.inspect_curvature_comb": "曲率コーム解析",
@@ -1133,6 +1136,7 @@
"dxf.progress.write": "DXFを書き込み中 (5/5)",
"dxf.tolerance": "曲線の許容誤差",
"dxf.tolerance_help": "表示中のボディとスケッチを出力し、曲線は線分で近似します。許容誤差を小さくすると曲線が滑らかになりますが、処理時間とメモリ使用量が増えます。",
"file.open.drawing_filter": "デザイン、CADファイル、メッシュ、図面",
"file_load.built": "構築済み: 全{total}フィーチャー中{built}件。",
"file_load.converting": "ソリッドを変換中: {done}/{total}",
"file_load.inserting": "ジオメトリを挿入中",
@@ -1371,6 +1375,7 @@
"pick the path": "パスを選択",
"prefs.add_item": "項目を追加",
"prefs.alt_frees_manipulators": "Alt / Option でマニピュレーターの矢印も自由に動かす",
"prefs.anti_aliasing_amd_dx12": "起動時のチェックでマルチサンプリングでは何も描画されず、1×では正常に描画されたため、アンチエイリアシングを1×に設定しました。上で別の設定を選択できます。再起動後のチェックに合格すると、その設定が適用されます。",
"prefs.anti_aliasing_note": "輪郭のぎざぎざを滑らかにします。",
"prefs.applies_on_restart": "再起動後に適用",
"prefs.assistant": "アシスタント",
@@ -2386,6 +2391,7 @@
"ui.agent_chat_mode_all": "すべて",
"ui.agent_chat_mode_ask": "確認",
"ui.agent_chat_mode_edits": "編集",
"ui.agent_chat_model_no_tools": "{model} は SolidCraft のツールを使えず、チャットのみ可能です。ツール対応のモデルを選んでください。",
"ui.agent_chat_model_tip": "エージェントが使うモデル。選択はこのエージェントに対して記憶されます。",
"ui.agent_chat_new": "新しいチャット",
"ui.agent_chat_new_confirm": "新しいチャットを始めますか? 現在のチャットは消去されます。",
@@ -2394,6 +2400,7 @@
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
"ui.agent_chat_no_picture": "クリップボードに画像がありません",
"ui.agent_chat_no_tool_call": "モデルが SolidCraft のツールを使わなかったため、デザインは変更されませんでした。ツール呼び出しに対応したモデルを選んでください。エージェントチャットのガイドの「ローカルモデル」を参照してください。",
"ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:",
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
"ui.agent_chat_not_installed": "未インストール",
@@ -2894,6 +2901,7 @@
"ui.one_side": "片側",
"ui.open": "開く",
"ui.open_ends_tip": "どこにも接していない",
"ui.open_linked_design": "リンクされたデザインを開く",
"ui.open_preferences": "環境設定を開く",
"ui.open_recent": "最近使った項目を開く",
"ui.operation": "操作",
@@ -3094,6 +3102,7 @@
"ui.total_angle": "合計角度",
"ui.trackpad_pan_tip": "2本指スクロールでパンします。FusionではShift + 2本指スクロールで回転します。オフの場合はスクロールでズームします。マウスホイールとピンチ操作はズーム、サイドホイールは横方向のパンになります。",
"ui.trim": "トリム",
"ui.turn_up_axis": "上方向の軸を回転",
"ui.twist": "ねじれ",
"ui.two_finger_scroll_pans": "2本指スクロールでパン",
"ui.two_sides": "両側",
@@ -3111,6 +3120,7 @@
"ui.updateToCurrentView": "現在のビューに更新",
"ui.vertical_constraint": "垂直拘束",
"ui.view_centre": "ビュー中心",
"ui.viewport_theme_follow": "テーマに従う",
"ui.visual_shaded_hidden_edges": "隠線付きシェーディング",
"ui.visual_shaded_with_edges": "エッジ付きシェーディング",
"ui.visual_style": "表示スタイル",
@@ -3175,6 +3185,8 @@
"update.up_to_date": "SolidCraft は最新です。",
"update.update_now": "今すぐ更新",
"update.you_have": "現在のバージョンは {version} です。",
"web.files.insert_drawings_hint": "CADファイル、メッシュ、DXF、SVGを開いているデザインに挿入",
"web.files.insert_stored_drawings_hint": "開いているデザインに挿入(CADファイル、メッシュ、DXF、SVG)",
"web.open_full": "SolidCraft で開く",
"web.open_link": "リンク先のデザインを開きますか?",
"web.open_link_confirm": "保存していない変更があります。リンク先のデザインを新しいタブで開きますか?",
+2
View File
@@ -2200,6 +2200,7 @@
"ui.one_side": "한 방향",
"ui.open": "열기",
"ui.open_ends_tip": "아무것과도 닿지 않음",
"ui.open_linked_design": "연결된 디자인 열기",
"ui.open_preferences": "환경 설정 열기",
"ui.open_recent": "최근 항목 열기",
"ui.operation": "작업",
@@ -2401,6 +2402,7 @@
"ui.up_axis_note": "뷰(축 뷰, 홈, 보기)에서 위쪽을 가리키는 축입니다. 디자인은 변경되지 않습니다.",
"ui.updateToCurrentView": "현재 보기로 업데이트",
"ui.view_centre": "뷰 중심",
"ui.viewport_theme_follow": "테마 따르기",
"ui.visual_shaded_hidden_edges": "음영 처리 및 숨겨진 모서리",
"ui.visual_shaded_with_edges": "음영 처리 및 모서리",
"ui.visual_style": "시각 스타일",
+12
View File
@@ -692,6 +692,7 @@
"cmd.component_capture_position": "捕获位置",
"cmd.component_copies": "查找相同副本",
"cmd.component_create": "新建组件",
"cmd.component_cycle_color": "循环切换组件颜色",
"cmd.component_delete": "删除组件",
"cmd.component_extract": "提取到文件",
"cmd.component_from_bodies": "由实体创建组件",
@@ -776,6 +777,8 @@
"cmd.inspect_accessibility": "可达性分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_clearance": "间隙",
"cmd.inspect_colored_components": "启用组件颜色",
"cmd.inspect_colored_components_disable": "禁用组件颜色",
"cmd.inspect_compare": "比较实体",
"cmd.inspect_continuity": "连续性分析",
"cmd.inspect_curvature_comb": "曲率梳分析",
@@ -1133,6 +1136,7 @@
"dxf.progress.write": "正在写入 DXF (5/5)",
"dxf.tolerance": "曲线公差",
"dxf.tolerance_help": "仅导出可见实体和显示的草图。曲线使用直线段近似。公差越小,曲线越精细,但需要更多时间和内存。",
"file.open.drawing_filter": "设计、CAD 文件、网格和图纸",
"file_load.built": "已构建:{built}/{total} 个特征。",
"file_load.converting": "正在转换实体 {done}/{total}",
"file_load.inserting": "正在插入几何体",
@@ -1371,6 +1375,7 @@
"pick the path": "选择路径",
"prefs.add_item": "添加项目",
"prefs.alt_frees_manipulators": "Alt / Option 也可自由拖动操纵器箭头",
"prefs.anti_aliasing_amd_dx12": "由于启动检查在启用多重采样时未绘制任何内容,而在1×时通过了检查,抗锯齿已设为1×。您可以在上方选择其他设置;重启后若检查通过,该设置将生效。",
"prefs.anti_aliasing_note": "平滑锯齿边缘。",
"prefs.applies_on_restart": "重启后生效",
"prefs.assistant": "助手",
@@ -2386,6 +2391,7 @@
"ui.agent_chat_mode_all": "全部",
"ui.agent_chat_mode_ask": "询问",
"ui.agent_chat_mode_edits": "编辑",
"ui.agent_chat_model_no_tools": "{model} 无法使用 SolidCraft 的工具,只能聊天。请选择支持工具的模型。",
"ui.agent_chat_model_tip": "智能体使用的模型。你的选择会为此智能体记住。",
"ui.agent_chat_new": "新对话",
"ui.agent_chat_new_confirm": "开始新对话?当前对话将被清除。",
@@ -2394,6 +2400,7 @@
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
"ui.agent_chat_no_images": "该智能体不接收图片",
"ui.agent_chat_no_picture": "剪贴板上没有图片",
"ui.agent_chat_no_tool_call": "模型没有使用 SolidCraft 的工具,因此设计没有更改。请选择支持工具调用的模型;参见智能体聊天指南中的“本地模型”。",
"ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:",
"ui.agent_chat_none_found_short": "未找到智能体",
"ui.agent_chat_not_installed": "未安装",
@@ -2894,6 +2901,7 @@
"ui.one_side": "单侧",
"ui.open": "打开",
"ui.open_ends_tip": "未接触任何曲线",
"ui.open_linked_design": "打开链接设计",
"ui.open_preferences": "打开首选项",
"ui.open_recent": "打开最近",
"ui.operation": "操作",
@@ -3094,6 +3102,7 @@
"ui.total_angle": "总角度",
"ui.trackpad_pan_tip": "双指滚动平移;Fusion 预设下,Shift + 双指滚动旋转视图。关闭时滚动缩放。鼠标滚轮和双指捏合缩放,拇指滚轮横向平移。",
"ui.trim": "修剪",
"ui.turn_up_axis": "旋转向上轴",
"ui.twist": "扭转",
"ui.two_finger_scroll_pans": "双指滚动平移",
"ui.two_sides": "双侧",
@@ -3111,6 +3120,7 @@
"ui.updateToCurrentView": "更新为当前视图",
"ui.vertical_constraint": "垂直约束",
"ui.view_centre": "视图中心",
"ui.viewport_theme_follow": "跟随主题",
"ui.visual_shaded_hidden_edges": "带隐藏边的着色",
"ui.visual_shaded_with_edges": "带边着色",
"ui.visual_style": "视觉样式",
@@ -3175,6 +3185,8 @@
"update.up_to_date": "SolidCraft 已是最新版本。",
"update.update_now": "立即更新",
"update.you_have": "当前版本为 {version}。",
"web.files.insert_drawings_hint": "将 CAD 文件、网格、DXF 或 SVG 插入当前设计",
"web.files.insert_stored_drawings_hint": "插入当前设计(CAD 文件、网格、DXF、SVG)",
"web.open_full": "在 SolidCraft 中打开",
"web.open_link": "打开链接的设计?",
"web.open_link_confirm": "有未保存的更改。要在新标签页中打开链接的设计吗?",
+166
View File
@@ -0,0 +1,166 @@
//! Identification colours, independent of materials and appearances.
use crate::Document;
/// Automatic colours select this palette in creation order; cycling uses the same order.
/// Colors are generated by picking colors on a color wheel, with 0.9 brightness and 0.8 saturation.
/// First pass is 3 colors, 120 huedegrees from each other. Second pass starts with 60 degrees from
/// the first one. Then third and fourth pass fill 60 and 30 degrees gaps between already generated
/// colors of the palette.
pub const PALETTE: [[u8; 3]; 24] = [
[230, 161, 92],
[92, 230, 161],
[161, 92, 230],
[161, 230, 92],
[92, 161, 230],
[230, 92, 161],
[230, 230, 92],
[92, 230, 92],
[92, 230, 230],
[92, 92, 230],
[230, 92, 230],
[230, 92, 92],
[230, 195, 92],
[195, 230, 92],
[126, 230, 92],
[92, 230, 126],
[92, 230, 195],
[92, 195, 230],
[92, 126, 230],
[126, 92, 230],
[195, 92, 230],
[230, 92, 195],
[230, 92, 126],
[230, 126, 92],
];
/// Select a colour in creation order, wrapping at the end of the palette.
pub fn automatic_color(index: u64) -> [u8; 3] {
PALETTE.get((index % PALETTE.len() as u64) as usize).copied().unwrap_or([230, 161, 92])
}
pub fn next_color(color: [u8; 3]) -> [u8; 3] {
let index = PALETTE.iter().position(|c| *c == color).map_or(0, |i| (i + 1) % PALETTE.len());
PALETTE.get(index).copied().unwrap_or([230, 161, 92])
}
impl Document {
pub fn component_identification_color(&self, id: u64) -> [u8; 3] {
self.components.iter().enumerate().find(|(_, c)| c.id == id).map_or_else(
|| automatic_color(0),
|(position, c)| c.identification_color.unwrap_or_else(|| automatic_color(c.identification_color_index.unwrap_or(position as u64))),
)
}
/// Fill older records in stored order and repair stale counters without changing saved indices.
pub(crate) fn normalise_component_color_indices(&mut self) -> crate::Result<()> {
let mut next = self.next_component_color_index;
for component in &self.components {
if let Some(index) = component.identification_color_index {
next = next.max(index.checked_add(1).ok_or_else(|| crate::DocError::Invalid("component colour sequence exhausted".into()))?);
}
}
for component in &mut self.components {
if component.identification_color_index.is_none() {
let following = next.checked_add(1).ok_or_else(|| crate::DocError::Invalid("component colour sequence exhausted".into()))?;
component.identification_color_index = Some(next);
next = following;
}
}
self.next_component_color_index = next;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn old_components_default_to_stable_colors_and_overrides_round_trip() {
let mut doc = Document::default();
let id = doc.add_component(Some("Part"), 0).unwrap();
let automatic = doc.component_identification_color(id);
let mut legacy: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
legacy["components"][0].as_object_mut().unwrap().remove("identification_color");
if let Some(pending) = legacy.get_mut("format_pending").and_then(|v| v.as_array_mut()) {
pending.retain(|name| name != "component-identification-colors");
}
let restored = Document::from_json(&legacy.to_string()).unwrap();
assert_eq!(restored.component_identification_color(id), automatic);
assert_eq!(restored.components[0].identification_color, None);
let override_color = next_color(automatic);
assert_ne!(override_color, automatic);
doc.components[0].identification_color = Some(override_color);
doc.components[0].name = "Renamed".into();
let saved = Document::from_json(&doc.to_json()).unwrap();
assert_eq!(saved.component_identification_color(id), override_color);
assert_eq!(saved.components[0].identification_color, Some(override_color));
}
#[test]
fn creation_order_wraps_and_survives_deletion_reordering_and_reopening() {
let mut doc = Document::default();
let mut ids = Vec::new();
for index in 0..25 {
// IDs also belong to occurrences, so their numeric values are not colour indices.
let id = doc.add_component(None, 0).unwrap();
assert_eq!(doc.component_identification_color(id), PALETTE[index % PALETTE.len()]);
ids.push(id);
}
let retained = ids[1];
doc.components.retain(|c| c.id != ids[0]);
doc.occurrences.retain(|o| o.component != ids[0]);
doc.components.reverse();
let mut restored = Document::from_json(&doc.to_json()).unwrap();
assert_eq!(restored.component_identification_color(retained), PALETTE[1]);
let next = restored.add_component(None, 0).unwrap();
assert_eq!(restored.component_identification_color(next), PALETTE[1]);
assert_eq!(restored.next_component_color_index, 26);
}
#[test]
fn legacy_order_and_overrides_are_preserved_and_stale_counters_repaired() {
let mut doc = Document::default();
let first = doc.add_component(None, 0).unwrap();
let second = doc.add_component(None, first).unwrap();
doc.components[1].identification_color = Some([1, 2, 3]);
let mut json: serde_json::Value = serde_json::from_str(&doc.to_json()).unwrap();
json.as_object_mut().unwrap().remove("next_component_color_index");
for component in json["components"].as_array_mut().unwrap() {
component.as_object_mut().unwrap().remove("identification_color_index");
}
if let Some(pending) = json.get_mut("format_pending").and_then(|v| v.as_array_mut()) {
pending.retain(|name| name != "sequential-component-colors");
}
let mut restored = Document::from_json(&json.to_string()).unwrap();
assert_eq!(restored.components[0].identification_color_index, Some(0));
assert_eq!(restored.components[1].identification_color_index, Some(1));
assert_eq!(restored.component_identification_color(first), PALETTE[0]);
assert_eq!(restored.component_identification_color(second), [1, 2, 3]);
let third = restored.add_component(None, 0).unwrap();
assert_eq!(restored.component_identification_color(third), PALETTE[2]);
restored.next_component_color_index = 0;
let mut repaired = Document::from_json(&restored.to_json()).unwrap();
let fourth = repaired.add_component(None, 0).unwrap();
assert_eq!(repaired.component_identification_color(fourth), PALETTE[3]);
let mut hostile: serde_json::Value = serde_json::from_str(&repaired.to_json()).unwrap();
hostile["components"][0]["identification_color_index"] = u64::MAX.into();
assert!(Document::from_json(&hostile.to_string()).is_err());
}
#[test]
fn cycling_visits_every_palette_color_and_wraps() {
let original = automatic_color(123);
let mut seen = std::collections::BTreeSet::new();
let mut color = original;
for _ in 0..PALETTE.len() {
assert!(seen.insert(color));
color = next_color(color);
}
assert_eq!(color, original);
assert_eq!(automatic_color(0), PALETTE[0]);
assert_eq!(automatic_color(1), PALETTE[1]);
assert_eq!(automatic_color(24), PALETTE[0]);
}
}
+496
View File
@@ -5,6 +5,10 @@
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
use serde::{Deserialize, Serialize};
use crate::{DocError, Result, expr};
/// Units per millimetre and the unit's name. Unknown names use millimetres.
pub fn unit_scale(units: &str) -> (f64, &'static str) {
match units {
@@ -39,6 +43,142 @@ pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
format!("{} {u}", length_number(mm, units, decimals))
}
/// The decimal separator of a number, independent of UI language.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecimalMarker {
Point,
Comma,
}
/// How an exact tie in the prepared decimal digits is rounded.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Ties {
HalfEven,
HalfAwayFromZero,
}
/// Number presentation for non-UI consumers. This does not change the legacy length helpers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NumberFormat {
pub marker: DecimalMarker,
pub groups: bool,
pub leading_zero: bool,
pub trailing_zeros: bool,
pub ties: Ties,
}
impl Default for NumberFormat {
fn default() -> Self {
Self { marker: DecimalMarker::Point, groups: false, leading_zero: true, trailing_zeros: true, ties: Ties::HalfEven }
}
}
/// Formatted text without a unit suffix, and the rounded number in the display unit.
#[derive(Clone, Debug, PartialEq)]
pub struct Shown {
pub text: String,
pub value: f64,
}
/// Format an internal value (millimetres or radians) in a strictly validated display unit.
pub fn format_value(value: f64, kind: expr::Kind, unit: &str, decimals: u8, f: &NumberFormat) -> Result<Shown> {
let (unit_kind, scale) = expr::unit_info(unit).ok_or_else(|| DocError::Invalid("unknown number display unit".into()))?;
if unit_kind != kind {
return Err(DocError::Invalid("number display unit has a different kind".into()));
}
format_display(value / scale, scale, decimals, f)
}
/// Format a unit-less number with the same validation and decimal rounding policy.
pub fn format_number(x: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
format_display(x, 1.0, decimals, f)
}
fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
// The unit table supplies a positive finite scale. Its decimal step must be at most
// 1e-9 internal units; the precision floor can make the effective tie window narrower.
let unit_resolution = (scale.log10() + 9.0).ceil().max(0.0) as usize;
let resolution = unit_resolution.max(usize::from(decimals) + 3);
let (rounded, value) = round_decimal(x, decimals, resolution, f.ties)?;
let (integer, fraction) = rounded.split_once('.').unwrap_or((&rounded, ""));
let fraction = if f.trailing_zeros { fraction } else { fraction.trim_end_matches('0') };
let integer = if !f.leading_zero && integer == "0" && !fraction.is_empty() { "" } else { integer };
let mut text = String::new();
if value.is_sign_negative() {
text.push('−');
}
grouped_digits(&mut text, integer, true, f.groups);
if !fraction.is_empty() {
text.push(match f.marker {
DecimalMarker::Point => '.',
DecimalMarker::Comma => ',',
});
grouped_digits(&mut text, fraction, false, f.groups);
}
Ok(Shown { text, value })
}
/// Prepare a bounded magnitude, then round decimal digits, never a binary tie comparison.
fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
if decimals > 8 {
return Err(DocError::Invalid("number precision must be at most 8 decimal places".into()));
}
if !x.is_finite() || x.abs() > 1e12 {
return Err(DocError::Invalid("display value must be finite with magnitude at most 1e12".into()));
}
let prepared = format!("{:.*}", resolution, x.abs());
let (integer, fraction) = prepared.split_once('.').unwrap_or((&prepared, ""));
let places = usize::from(decimals);
let mut digits: Vec<u8> = integer.bytes().chain(fraction.bytes().take(places)).collect();
let discarded = fraction.as_bytes().get(places..).unwrap_or_default();
let up = match discarded.first().copied() {
Some(b'6'..=b'9') => true,
Some(b'5') => {
discarded.get(1..).unwrap_or_default().iter().any(|d| *d != b'0')
|| match ties {
Ties::HalfEven => digits.last().is_some_and(|d| !(d - b'0').is_multiple_of(2)),
Ties::HalfAwayFromZero => true,
}
}
_ => false,
};
if up {
let mut carry = true;
for d in digits.iter_mut().rev() {
if *d == b'9' {
*d = b'0';
} else {
*d += 1;
carry = false;
break;
}
}
if carry {
digits.insert(0, b'1');
}
}
if places > 0 {
let point = digits.len().checked_sub(places).ok_or_else(|| DocError::Invalid("invalid prepared number".into()))?;
digits.insert(point, b'.');
}
let rounded = String::from_utf8(digits).map_err(|_| DocError::Invalid("invalid prepared digits".into()))?;
let magnitude = rounded.parse::<f64>().map_err(|_| DocError::Invalid("invalid rounded number".into()))?;
let negative = x.is_sign_negative() && magnitude != 0.0;
Ok((rounded, if negative { -magnitude } else { magnitude }))
}
fn grouped_digits(text: &mut String, digits: &str, integer: bool, groups: bool) {
for (i, digit) in digits.chars().enumerate() {
if groups && digits.len() >= 5 && i > 0 && if integer { (digits.len() - i).is_multiple_of(3) } else { i.is_multiple_of(3) } {
text.push('\u{202f}');
}
text.push(digit);
}
}
#[cfg(test)]
mod tests {
use super::{format_length, length_number, length_precision, unit_scale};
@@ -121,3 +261,359 @@ mod tests {
assert_eq!(format_length(f64::NEG_INFINITY, "cm", 2), "-inf cm");
}
}
#[cfg(test)]
mod number_tests {
use super::*;
use expr::Kind::{Angle, Length, Unitless};
fn pair(x: f64, dp: u8, even: &str, away: &str) {
for (ties, expected) in [(Ties::HalfEven, even), (Ties::HalfAwayFromZero, away)] {
let shown = format_number(x, dp, &NumberFormat { ties, ..NumberFormat::default() }).unwrap();
assert_eq!(shown.text, expected, "x={x:?}, dp={dp}, ties={ties:?}");
let parsed: f64 = expected.replace('−', "-").parse().unwrap();
assert_eq!(shown.value, parsed);
}
}
#[test]
fn number_exact_binary_ties() {
for (x, dp, even, away) in [
(0.125, 2, "0.12", "0.13"),
(0.375, 2, "0.38", "0.38"),
(12.5, 0, "12", "13"),
(-12.5, 0, "−12", "−13"),
(13.5, 0, "14", "14"),
(-13.5, 0, "−14", "−14"),
] {
pair(x, dp, even, away);
}
}
#[test]
fn number_decimal_ties() {
for (x, even, away) in [(2.675, "2.68", "2.68"), (2.665, "2.66", "2.67"), (1.005, "1.00", "1.01"), (0.285, "0.28", "0.29")] {
pair(x, 2, even, away);
}
// The existing UI policy deliberately remains different.
assert_eq!(length_number(2.665, "mm", 2), "2.67");
assert_eq!(length_number(2.675, "mm", 2), "2.67");
assert_eq!(length_number(1.005, "mm", 2), "1.00");
assert_eq!(length_number(0.285, "mm", 2), "0.28");
}
#[test]
fn number_tie_zone_edges_and_precision_floor() {
for (x, expected) in [(61.49999999999999, "62"), (61.4999999996, "62"), (61.4999999994, "61"), (61.4999997, "61"), (61.4996, "61")] {
pair(x, 0, expected, expected);
}
pair(1.0000000049, 8, "1.00000000", "1.00000000");
pair(2.4501, 1, "2.5", "2.5");
pair(2.5499, 1, "2.5", "2.5");
}
#[test]
fn number_carries() {
pair(9.995, 2, "10.00", "10.00");
pair(-9.995, 2, "−10.00", "−10.00");
pair(99.5, 0, "100", "100");
pair(0.999999995, 8, "1.00000000", "1.00000000");
}
#[test]
fn number_units_and_aliases() {
let f = NumberFormat::default();
for (x, kind, unit, dp, expected) in [
(25.4, Length, "in", 3, "1.000"),
(25.4 * (1.0 + 1e-12), Length, "in", 3, "1.000"),
(25.4, Length, "in", 0, "1"),
(12.5, Length, "cm", 2, "1.25"),
(3810.0, Length, "ft", 1, "12.5"),
(std::f64::consts::PI / 6.0, Angle, "deg", 1, "30.0"),
(1.0, Angle, "deg", 3, "57.296"),
(1.0, Angle, "rad", 3, "1.000"),
(0.001, Length, "um", 3, "1.000"),
(0.001, Length, "µm", 3, "1.000"),
(1e-6, Length, "nm", 3, "1.000"),
(0.0254, Length, "mil", 3, "1.000"),
(914.4, Length, "yd", 3, "1.000"),
(1e6, Length, "km", 3, "1.000"),
(25.4, Length, " inch ", 3, "1.000"),
(std::f64::consts::PI, Angle, "°", 1, "180.0"),
(std::f64::consts::PI, Angle, "degrees", 1, "180.0"),
(1.25, Unitless, "", 2, "1.25"),
(1.25, Unitless, "none", 2, "1.25"),
(1.25, Unitless, "unitless", 2, "1.25"),
] {
let shown = format_value(x, kind, unit, dp, &f).unwrap();
assert_eq!(shown.text, expected, "{x:?} {kind:?} in {unit:?}");
assert_eq!(shown.value, expected.parse::<f64>().unwrap());
}
for (kind, unit) in [(Length, "deg"), (Angle, "mm"), (Length, ""), (Length, "parsec"), (Unitless, "mm"), (Length, "IN")] {
assert!(matches!(format_value(1.0, kind, unit, 2, &f), Err(DocError::Invalid(_))), "{kind:?} {unit:?}");
}
}
#[test]
fn number_unit_resolution_edges() {
let f = NumberFormat { ties: Ties::HalfAwayFromZero, ..NumberFormat::default() };
for (kind, unit, resolution) in [
(Length, "mm", 9),
(Length, "cm", 10),
(Length, "in", 11),
(Length, "m", 12),
(Length, "ft", 12),
(Angle, "deg", 8),
(Unitless, "", 9),
(Length, "nm", 3),
(Length, "km", 15),
] {
let scale = expr::unit_info(unit).unwrap().1;
let quantum = 10.0_f64.powi(-resolution);
for (distance, expected) in [(0.4, "1"), (0.6, "0")] {
let internal = (0.5 - distance * quantum) * scale;
assert_eq!(
format_value(internal, kind, unit, 0, &f).unwrap().text,
expected,
"{unit:?}, resolution={resolution}, distance={distance}"
);
}
}
}
#[test]
fn number_zero_sign_caps_and_shown_value() {
for x in [-0.0, -0.004, f64::from_bits(1)] {
let shown = format_number(x, 2, &NumberFormat::default()).unwrap();
assert_eq!(shown.text, "0.00");
assert_eq!(shown.value.to_bits(), 0.0_f64.to_bits());
}
pair(-1.5, 1, "−1.5", "−1.5");
pair(12.5, 8, "12.50000000", "12.50000000");
let f = NumberFormat::default();
for dp in [9, 255] {
assert!(matches!(format_number(12.5, dp, &f), Err(DocError::Invalid(_))));
assert!(matches!(format_value(25.4, Length, "in", dp, &f), Err(DocError::Invalid(_))));
}
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e13, -1e13, 1e12_f64.next_up(), -1e12_f64.next_up()] {
assert!(matches!(format_number(x, 2, &f), Err(DocError::Invalid(_))), "{x:?}");
assert!(matches!(format_value(x, Length, "mm", 2, &f), Err(DocError::Invalid(_))), "{x:?}");
}
for x in [-1e12, 1e12] {
assert_eq!(format_number(x, 8, &f).unwrap().value, x);
}
assert_eq!(format_value(1e15, Length, "m", 8, &f).unwrap().value, 1e12, "cap is in display units");
assert_eq!(format_number(2.665, 2, &f).unwrap().value, 2.66);
assert_eq!(format_value(25.4, Length, "in", 3, &f).unwrap().value, 1.0);
}
#[test]
fn number_format_options_and_group_boundaries() {
let base = NumberFormat::default();
let comma = NumberFormat { marker: DecimalMarker::Comma, ..base };
assert_eq!(format_number(2.665, 2, &comma).unwrap().text, "2,66");
let no_leading = NumberFormat { leading_zero: false, ..base };
assert_eq!(format_number(0.5, 3, &no_leading).unwrap().text, ".500");
let trim = NumberFormat { trailing_zeros: false, ..base };
assert_eq!(format_number(25.0, 2, &trim).unwrap().text, "25");
assert_eq!(format_number(25.5, 2, &trim).unwrap().text, "25.5");
assert_eq!(format_number(0.5, 3, &NumberFormat { leading_zero: false, ..trim }).unwrap().text, ".5");
let groups = NumberFormat { groups: true, ..base };
for (x, dp, expected) in [
(12345.6789, 4, "12\u{202f}345.6789"),
(1.23456, 5, "1.234\u{202f}56"),
(1234.5678, 4, "1234.5678"),
(123456.0, 0, "123\u{202f}456"),
(1234.0, 0, "1234"),
(12345.0, 0, "12\u{202f}345"),
(1.23456789, 8, "1.234\u{202f}567\u{202f}89"),
(-12345.6789, 4, "−12\u{202f}345.6789"),
(9999.9, 0, "10\u{202f}000"),
] {
assert_eq!(format_number(x, dp, &groups).unwrap().text, expected, "{x:?}, dp={dp}");
}
assert_eq!(format_number(12345.6789, 4, &NumberFormat { groups: true, ..comma }).unwrap().text, "12\u{202f}345,6789");
assert_eq!(format_number(1.23450, 5, &NumberFormat { groups: true, ..trim }).unwrap().text, "1.2345", "group printed digits after trimming");
}
#[test]
fn number_provisional_leading_zero_for_rounded_zero() {
// Provisional #5290 assumption: a fractional zero also omits the integer zero.
let f = NumberFormat { leading_zero: false, ..NumberFormat::default() };
for x in [0.0, -0.0, -0.004] {
assert_eq!(format_number(x, 2, &f).unwrap().text, ".00");
assert_eq!(format_number(x, 0, &f).unwrap().text, "0");
assert_eq!(format_number(x, 2, &NumberFormat { trailing_zeros: false, ..f }).unwrap().text, "0");
}
assert_eq!(format_number(-0.5, 2, &f).unwrap().text, "−.50");
assert_eq!(format_number(0.0, 2, &NumberFormat { marker: DecimalMarker::Comma, ..f }).unwrap().text, ",00");
}
#[test]
fn number_provisional_serde_convention_and_strict_fields() {
// Provisional #5290 assumption, matching expr::Kind and other doc enums.
let f: NumberFormat = serde_json::from_str("{}").unwrap();
assert_eq!(f, NumberFormat::default());
assert_eq!(
serde_json::to_value(f).unwrap(),
serde_json::json!({
"marker": "point", "groups": false, "leading_zero": true,
"trailing_zeros": true, "ties": "half_even"
})
);
let f: NumberFormat = serde_json::from_value(serde_json::json!({"marker":"comma", "ties":"half_away_from_zero"})).unwrap();
assert_eq!(f.marker, DecimalMarker::Comma);
assert_eq!(f.ties, Ties::HalfAwayFromZero);
assert!(f.leading_zero && f.trailing_zeros && !f.groups);
assert_eq!(serde_json::from_value::<NumberFormat>(serde_json::to_value(f).unwrap()).unwrap(), f);
for bad in [serde_json::json!({"locale":"en"}), serde_json::json!({"marker":"Point"}), serde_json::json!({"ties":"unknown"})] {
assert!(serde_json::from_value::<NumberFormat>(bad).is_err());
}
}
fn random(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
/// Compare the printed decimal exactly with the input's binary fraction. A second f64
/// subtraction can exceed the bound solely because parsing the correct text rounds again.
fn decimal_within_bound(x: f64, text: &str, dp: u8, resolution: usize) -> bool {
let unsigned = text.trim_start_matches('-');
let (whole, fraction) = unsigned.split_once('.').unwrap_or((unsigned, ""));
let coefficient: u128 = format!("{whole}{fraction}").parse().unwrap();
let coefficient = coefficient * 10_u128.pow(u32::from(dp) - fraction.len() as u32);
let extra = 10_u128.pow((resolution - usize::from(dp)) as u32);
// Multiply |text - input| <= (10^-dp + 10^-resolution)/2 by 2*10^resolution.
let center = 2 * coefficient * extra;
let radius = extra + 1;
let bits = x.abs().to_bits();
let exponent = ((bits >> 52) & 0x7ff) as u32;
let mantissa = u128::from(bits & ((1_u64 << 52) - 1));
let (mantissa, shift) = if exponent == 0 { (mantissa, 1074) } else { (mantissa | (1_u128 << 52), 1075 - exponent) };
let input = 2 * mantissa * 10_u128.pow(resolution as u32);
// Input is below 2^104 at the supported resolutions. A shifted bound above u128's
// range is therefore certainly above input; saturation preserves the comparison.
let shifted = |n: u128| {
if n == 0 {
0
} else if shift >= 128 || n > (u128::MAX >> shift) {
u128::MAX
} else {
n << shift
}
};
shifted(center.saturating_sub(radius)) <= input && input <= shifted(center + radius)
}
#[test]
fn number_large_magnitude_decimal_error_bound() {
let x = 34947628153.461784;
let shown = format_number(x, 5, &NumberFormat::default()).unwrap();
assert_eq!(shown.text, "34947628153.46178");
assert!(decimal_within_bound(x, &shown.text, 5, 9));
assert!(!decimal_within_bound(x, "34947628153.46177", 5, 9));
// Another exact binary tie whose rounded decimal has an inexact f64 representation.
let x = 100000000000.25;
let shown = format_number(x, 1, &NumberFormat::default()).unwrap();
assert_eq!(shown.text, "100000000000.2");
assert!(decimal_within_bound(x, &shown.text, 1, 9));
}
#[test]
fn number_seeded_100k_properties() {
// Reference resolutions derived by hand from the unit table, not from the formatter.
let units = [
("mm", 9),
("cm", 10),
("m", 12),
("in", 11),
("ft", 12),
("yd", 12),
("km", 15),
("um", 6),
("µm", 6),
("nm", 3),
("mil", 8),
("inch", 11),
("deg", 8),
("rad", 9),
("°", 8),
("degree", 8),
("degrees", 8),
("", 9),
("none", 9),
("unitless", 9),
(" in ", 11),
("parsec", 9),
("IN", 9),
("\0", 9),
("../../mm", 9),
("🦀", 9),
];
let mut seed = 0x5290_a735_19d2_8bc1;
let (mut accepted, mut rejected, mut ordered) = (0, 0, 0);
for case in 0..100_000 {
let x = f64::from_bits(random(&mut seed));
let dp = (random(&mut seed) % 13) as u8;
let bits = random(&mut seed);
let f = NumberFormat {
marker: if bits & 1 == 0 { DecimalMarker::Point } else { DecimalMarker::Comma },
groups: bits & 2 != 0,
leading_zero: bits & 4 != 0,
trailing_zeros: bits & 8 != 0,
ties: if bits & 16 == 0 { Ties::HalfEven } else { Ties::HalfAwayFromZero },
};
let kind = match random(&mut seed) % 3 {
0 => Length,
1 => Angle,
_ => Unitless,
};
let (unit, base_resolution) = units[(random(&mut seed) % units.len() as u64) as usize];
let bare = case % 2 == 0;
let info = if bare { Some((Unitless, 1.0)) } else { expr::unit_info(unit).filter(|(k, _)| *k == kind) };
let call = |v| if bare { format_number(v, dp, &f) } else { format_value(v, kind, unit, dp, &f) };
let result = call(x);
let display = info.map(|(_, scale)| x / scale);
let valid = dp <= 8 && display.is_some_and(|v| v.is_finite() && v.abs() <= 1e12);
if !valid {
rejected += 1;
assert!(matches!(result, Err(DocError::Invalid(_))), "case {case}: {x:?}, dp={dp}, {kind:?}, {unit:?}: {result:?}");
continue;
}
accepted += 1;
let shown = result.unwrap();
let canonical = shown.text.replace(',', ".").replace('\u{202f}', "").replace('−', "-");
let parsed: f64 = canonical.parse().unwrap();
assert_eq!(shown.value.to_bits(), parsed.to_bits(), "case {case}: {shown:?}");
assert!(!shown.text.contains('-'));
if parsed == 0.0 {
assert!(!shown.value.is_sign_negative() && !shown.text.starts_with('−'));
}
let step = 10.0_f64.powi(-i32::from(dp));
let resolution = if bare { 9 } else { base_resolution }.max(usize::from(dp) + 3);
assert!(
decimal_within_bound(display.unwrap(), &canonical, dp, resolution),
"case {case}: input={x:?}, dp={dp}, unit={unit:?}, {shown:?}"
);
if parsed != 0.0 {
assert_eq!(parsed.is_sign_negative(), x.is_sign_negative());
}
// Shown.value already has display units; converting it again would be wrong.
assert_eq!(format_number(shown.value, dp, &f).unwrap().text, shown.text, "idempotence case {case}");
// Fixed settings/units; move by >=2 display steps and several input ULPs.
// Unlike next_up alone, this requires actual output increases, not just equal zeros.
let scale = info.unwrap().1;
let delta = (2.0 * step * scale).max(x.abs() * f64::EPSILON * 4.0);
let y = x + delta;
if let Ok(next) = call(y) {
ordered += 1;
assert!(y > x && next.value > shown.value, "monotonicity case {case}: {x:?}->{y:?}, dp={dp}, unit={unit:?}: {shown:?}->{next:?}");
}
}
assert!(accepted > 10_000 && rejected > 10_000 && ordered > 10_000, "coverage: accepted={accepted}, rejected={rejected}, ordered={ordered}");
eprintln!("seed=0x5290a73519d28bc1 cases=100000 accepted={accepted} rejected={rejected} strictly_ordered={ordered}");
}
}
+53 -2
View File
@@ -1957,6 +1957,12 @@ pub struct SketchPoints {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Component {
pub id: u64,
/// Optional identification colour, separate from normal appearances.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identification_color: Option<[u8; 3]>,
/// Creation sequence used to select an automatic identification colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identification_color_index: Option<u64>,
pub name: String,
/// Parent component (0 = the root).
#[serde(default)]
@@ -2536,6 +2542,9 @@ pub struct Document {
/// Components below the root, as a tree (by parent).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<Component>,
/// Next automatic colour sequence number; deletions never rewind it.
#[serde(default)]
pub next_component_color_index: u64,
/// Placements of components in their parents (each non-root component has at least one).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub occurrences: Vec<crate::Occurrence>,
@@ -2800,6 +2809,7 @@ impl Document {
next_id: 1,
reserved_body_names: Default::default(),
components: Vec::new(),
next_component_color_index: 0,
occurrences: Vec::new(),
assembly: Default::default(),
sheet: Default::default(),
@@ -3114,6 +3124,9 @@ impl Document {
if self.components.len() >= 10_000 {
return Err(DocError::Invalid("too many components".into()));
}
self.normalise_component_color_indices()?;
let color_index = self.next_component_color_index;
let next_color_index = color_index.checked_add(1).ok_or_else(|| DocError::Invalid("component colour sequence exhausted".into()))?;
let id = self.next_id.max(1);
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
let name = match name.map(str::trim) {
@@ -3126,7 +3139,8 @@ impl Document {
format!("Component{k}")
}
};
self.components.push(Component { id, name, parent, ..Default::default() });
self.components.push(Component { id, name, parent, identification_color_index: Some(color_index), ..Default::default() });
self.next_component_color_index = next_color_index;
let occurrence = self.add_occurrence(id, parent, crate::IDENTITY)?;
// The design's first part is grounded, so joints and drags have something fixed to work
// from (#2804); later parts and copies start free. Only creation decides this: opening a
@@ -3652,6 +3666,7 @@ impl Document {
/// parameters (in place).
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
self.apply_point_values(vals, sk)?;
self.apply_pattern_values(vals, sk)?;
for c in &mut sk.constraints {
if let Some(p) = &c.param {
let kind = if c.kind.is_angle() { Kind::Angle } else { Kind::Length };
@@ -3683,6 +3698,43 @@ impl Document {
Ok(())
}
/// Evaluate the retained pattern inputs before solving its associative relations.
pub fn apply_pattern_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
use solidcraft_sketch::SketchPatternKind;
self.apply_pattern_quantities(vals, sk)?;
for index in 0..sk.patterns.len() {
let Some(pattern) = sk.patterns.get(index) else { continue };
let mut kind = pattern.kind.clone();
for (key, expression) in &pattern.expressions {
let unit = match key.as_str() {
"count" | "count2" => Kind::Unitless,
"spacing" | "spacing2" => Kind::Length,
"angle" => Kind::Angle,
_ => return Err(DocError::Invalid(format!("unknown sketch pattern input `{key}`"))),
};
let value = crate::eval::val(vals, expression, unit).map_err(|e| DocError::Expr(format!("pattern {} `{key}`: {e}", pattern.id)))?;
let count = || -> Result<usize> {
if !(1.0..=500.0).contains(&value) || value.fract() != 0.0 {
return Err(DocError::Invalid(format!("pattern `{key}` must be a positive integer from 1 to 500")));
}
Ok(value as usize)
};
match (&mut kind, key.as_str()) {
(SketchPatternKind::Circular { count: n, .. } | SketchPatternKind::Rectangular { count: n, .. }, "count") => *n = count()?,
(SketchPatternKind::Rectangular { count2, .. }, "count2") => *count2 = count()?,
(SketchPatternKind::Rectangular { spacing, .. }, "spacing") => *spacing = value,
(SketchPatternKind::Rectangular { spacing2, .. }, "spacing2") => *spacing2 = value,
(SketchPatternKind::Circular { angle, .. }, "angle") => *angle = value.to_degrees(),
_ => return Err(DocError::Invalid(format!("invalid sketch pattern input `{key}`"))),
}
}
if kind != pattern.kind {
sk.update_pattern(index, kind)?;
}
}
Ok(())
}
/// Move the points placed by parameters to where their expressions come to (in place). Such
/// a point is fixed, so the solve keeps it there.
pub fn apply_point_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
@@ -3778,7 +3830,6 @@ impl Document {
moved_from: Option<&Sketch>,
analysis: solidcraft_sketch::Analysis,
) -> Result<solidcraft_sketch::SolveReport> {
self.apply_pattern_quantities(vals, sk)?;
self.apply_dimension_values(vals, sk)?;
let was = |sk: &Sketch| moved_from.and_then(|from| sk.positions_in(from));
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
+33 -2
View File
@@ -68,6 +68,9 @@ pub struct ModelBody {
/// [`kernel::measure`] of `body` (with the body it was measured on), worked out once and
/// shared by clones: inspecting a design again measures only the bodies that changed.
measured: Arc<OnceLock<(Body, kernel::Result<kernel::BodyMeasure>)>>,
/// [`Body::is_surface`] of `body` (with the body it was measured on), worked out once and
/// shared by clones.
surface: Arc<OnceLock<(Body, bool)>>,
}
impl ModelBody {
@@ -93,6 +96,16 @@ impl ModelBody {
source_key: 0,
outline: Arc::new(OnceLock::new()),
measured: Arc::new(OnceLock::new()),
surface: Arc::new(OnceLock::new()),
}
}
/// A surface body: some shell does not close ([`Body::is_surface`]), worked out once.
pub fn is_surface(&self) -> bool {
match self.surface.get_or_init(|| (self.body.clone(), self.body.is_surface())) {
(measured, s) if measured.same(&self.body) => *s,
// The body was replaced in this cell's holder since: look again.
_ => self.body.is_surface(),
}
}
@@ -1183,6 +1196,7 @@ fn param_names(f: &Feature) -> Vec<String> {
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
names.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values()).flat_map(|e| expr::references(e)));
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
names.extend(sketch.text_word_exprs().into_iter().flat_map(|e| crate::text_params::references(e)));
}
@@ -1330,13 +1344,28 @@ fn feature_json(f: &Feature, steps: &StepDigests) -> String {
/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's
/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not
/// match `stored` entity for entity is not adopted.
fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sketch::Sketch) {
fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sketch::Sketch) -> bool {
if stored.patterns != solved.patterns {
// Rebuild authored copies without storing topology derived from links or offsets.
let mut updated = stored.clone();
for pattern in &solved.patterns {
let Some(index) = updated.patterns.iter().position(|p| p.id == pattern.id) else { return false };
if updated.update_pattern(index, pattern.kind.clone()).is_err() {
return false;
}
}
if updated.patterns != solved.patterns || !adopt_solution(&mut updated, solved) {
return false;
}
*stored = updated;
return true;
}
if stored.points.len() != solved.points.len()
|| stored.curves.len() != solved.curves.len()
|| stored.constraints.len() != solved.constraints.len()
|| stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id)
{
return;
return false;
}
for (p, q) in stored.points.iter_mut().zip(&solved.points) {
p.pos = q.pos;
@@ -1358,6 +1387,7 @@ fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sk
c.kind.set_value(v);
}
}
true
}
/// The values features are evaluated with: the parameters, and the design's length unit.
@@ -1864,6 +1894,7 @@ impl Model {
s.report.keeps_orientation()
&& doc.sketch(s.feature).is_ok_and(|stored| {
stored.driving_values() != s.sketch.driving_values()
|| stored.patterns != s.sketch.patterns
|| s.sketch.points.iter().zip(&stored.points).any(|(p, q)| p.link.is_some() && p.id == q.id && p.pos != q.pos)
})
})
+1
View File
@@ -1144,6 +1144,7 @@ fn rigid(m: &crate::Mat) -> bool {
/// Feature input names and component occurrences, which format 1 files may predate.
fn normalise(d: &mut Document) -> Result<()> {
d.normalise_component_color_indices()?;
d.name_all_feature_inputs();
let missing: Vec<(u64, u64)> =
d.components.iter().filter(|c| !d.occurrences.iter().any(|o| o.component == c.id)).map(|c| (c.id, c.parent)).collect();
@@ -0,0 +1,4 @@
//! Components save an optional `identification_color_index` and the document saves
//! `next_component_color_index`. Automatic colours follow creation order and wrap through the
//! palette; deletions do not rewind the sequence. Older records receive indices in stored order
//! during normalization (not the order they were originally created), preserving explicit RGB overrides.
@@ -0,0 +1,5 @@
//! An edge joint origin (`Snap::Edge`) saves an optional `axis`: `{"along": [x, y, z]}` (z runs
//! along the edge, the way the vector points) or `{"fixed": [x, y, z]}` (z is that direction), for
//! an edge or a corner picked in the Joint dialog, which used to be saved as a point. Older
//! designs have no `axis` and resolve as before (z out of the face). The last release refuses a
//! design with this change by its newer format number.
@@ -0,0 +1,2 @@
//! Sketch patterns retain count, spacing and angle expressions alongside evaluated settings.
//! Missing expressions preserve the numeric settings of older designs without rewriting them.
+25 -2
View File
@@ -75,6 +75,10 @@ pub enum Snap {
face: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
corner: Option<String>,
/// The frame's z when it isn't the face's normal (an edge or a corner picked as it is,
/// not on a face): the point still follows the edge, found by name.
#[serde(default, skip_serializing_if = "Option::is_none")]
axis: Option<EdgeAxis>,
},
/// A point, z along `z` (default +Z) and x toward `x` laid square to z (default: one chosen
/// from z alone).
@@ -87,6 +91,25 @@ pub enum Snap {
},
}
/// The z of an edge origin that isn't its face's normal (component coordinates); its x follows
/// from z alone, as a point origin's does.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EdgeAxis {
/// Along a straight edge, the way this points: a hinge on the edge turns about it as it moves.
Along(Vec3),
/// This direction.
Fixed(Vec3),
}
impl EdgeAxis {
pub fn dir(self) -> Vec3 {
match self {
EdgeAxis::Along(d) | EdgeAxis::Fixed(d) => d,
}
}
}
/// What sets a joint origin's x axis (its turn about z): a direction, or a face found by its
/// persistent name, so a round origin keeps its turn when the design changes.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -179,7 +202,7 @@ impl Snap {
Snap::Frame { origin, z, x } => origin.is_finite() && z.is_finite() && x.is_none_or(|x| x.is_finite()),
Snap::FaceCenter { pick, x, .. } | Snap::CircleCenter { pick, x, .. } => pick.is_finite() && x_ok(x),
Snap::Point { point, z, x } => point.is_finite() && z.is_none_or(|z| z.is_finite()) && x.is_none_or(|x| x.is_finite()),
Snap::Edge { pick, .. } => pick.is_finite(),
Snap::Edge { pick, axis, .. } => pick.is_finite() && axis.is_none_or(|a| a.dir().is_finite()),
}
}
}
@@ -2079,7 +2102,7 @@ pub mod cam;
#[path = "joints_edge.rs"]
mod edge;
pub use edge::edge_snap;
pub use edge::{edge_axis, edge_snap};
#[path = "joints_reach.rs"]
mod reach;
+35 -3
View File
@@ -10,7 +10,7 @@
use solidcraft_geom::Vec3;
use super::{Snap, component_bodies, frame};
use super::{EdgeAxis, Snap, component_bodies, frame};
use crate::{DocError, Document, IDENTITY, Mat, ModelBody, ModelState, Result, apply_point, mat_inverse, mat_mul};
/// A body's placement in its component and its inverse: a body moved in from another component
@@ -108,7 +108,7 @@ fn meeting(e: &[Vec3], f: &[Vec3], tol: f64) -> Option<Vec3> {
/// The frame of an edge snap in its component's coordinates.
pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap) -> Result<Mat> {
let Snap::Edge { pick, edge, face, corner } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
let Snap::Edge { pick, edge, face, corner, axis } = snap else { return Err(DocError::Invalid("not an edge origin".into())) };
let bad = || DocError::Invalid("joint origin: degenerate frame".into());
let gone = |what: &str| DocError::Invalid(format!("joint origin: {what} no longer exists (edit the joint to pick again)"));
let found = component_bodies(doc, st, comp).into_iter().find_map(|b| {
@@ -143,6 +143,27 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
frame(at, z, Some(x)).ok_or_else(bad)
}
}?;
// A z of its own: x from z alone, at the point the edge gives.
let local = match axis {
None => local,
Some(a) => {
let at = Vec3::new(local[3][0], local[3][1], local[3][2]);
let (_, inv) = placement(doc, b);
let want = crate::apply_vector(&inv, a.dir());
let z = match a {
EdgeAxis::Fixed(_) => want,
// The edge's run, the way `want` points.
EdgeAxis::Along(_) => {
let run = match (e.first(), e.last()) {
(Some(p), Some(q)) => (*q - *p).normalized().ok_or_else(bad)?,
_ => return Err(bad()),
};
if run.dot(want) < 0.0 { -run } else { run }
}
};
frame(at, z, None).ok_or_else(bad)?
}
};
Ok(mat_mul(&placed, &local))
}
@@ -207,5 +228,16 @@ pub fn edge_snap(doc: &Document, st: &ModelState, comp: u64, pick: Vec3, on: Opt
)
}
};
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner })
Ok(Snap::Edge { pick, edge: names.get(i).cloned().filter(|n| !n.is_empty()), face, corner, axis: None })
}
/// Edge snap `snap` of component `comp` given z `z` (component coordinates) of its own: along the
/// edge when the edge runs that way (so a hinge on it keeps turning about it), else fixed.
pub fn edge_axis(doc: &Document, st: &ModelState, comp: u64, snap: Snap, z: Vec3) -> Snap {
let Snap::Edge { pick, edge, face, corner, .. } = snap else { return snap };
let with = |axis| Snap::Edge { pick, edge: edge.clone(), face: face.clone(), corner: corner.clone(), axis: Some(axis) };
let along = with(EdgeAxis::Along(z));
let runs =
edge_frame(doc, st, comp, &along).is_ok_and(|m| z.normalized().is_some_and(|z| Vec3::new(m[2][0], m[2][1], m[2][2]).dot(z) > 1.0 - 1e-9));
if runs { along } else { with(EdgeAxis::Fixed(z)) }
}
+1
View File
@@ -15,6 +15,7 @@ pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
mod clipboard;
pub use clipboard::{clone_expression, remap_topology};
pub mod component_color;
mod component_removal;
pub use component_removal::RemovedTree;
pub mod config;
+7 -2
View File
@@ -78,6 +78,7 @@ impl FeatureKind {
inputs.extend(placement);
if let FeatureKind::Sketch { sketch, .. } = self {
inputs.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()));
inputs.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values().map(String::as_str)));
}
inputs
}
@@ -738,7 +739,9 @@ impl Document {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: format!("{} Quantity", pattern.id) });
}
}
for (what, e) in sketch.text_exprs() {
for (what, e) in
sketch.text_exprs().into_iter().chain(sketch.patterns.iter().flat_map(|p| p.expressions.iter().map(|(k, e)| (k.as_str(), e))))
{
if expr::references(e).iter().any(|r| r == name) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
}
@@ -857,7 +860,7 @@ impl Document {
n += 1;
}
}
for e in sketch.patterns.iter_mut().filter_map(|p| p.count_expression.as_mut()) {
for e in sketch.patterns.iter_mut().flat_map(|p| p.count_expression.iter_mut().chain(p.expressions.values_mut())) {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
@@ -964,6 +967,7 @@ impl Document {
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
names.extend(sketch.patterns.iter().flat_map(|p| p.expressions.values()).flat_map(|e| expr::references(e)));
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
names.extend(sketch.patterns.iter().filter_map(|p| p.count_expression.as_deref()).flat_map(expr::references));
names.extend(sketch.text_word_exprs().into_iter().flat_map(|e| crate::text_params::references(e)));
@@ -1007,6 +1011,7 @@ impl Document {
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
hit |= sketch.patterns.iter().flat_map(|p| p.expressions.values()).any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
hit |= sketch.text_exprs().iter().any(|(_, e)| expr::references(e).iter().any(|r| affected.contains(r)));
hit |= sketch.text_word_exprs().iter().any(|e| crate::text_params::references(e).iter().any(|r| affected.contains(r)));
}
@@ -117,6 +117,47 @@ fn revolve_cut_round_a_box_corner_does_not_panic() {
}
}
/// #5543: a grid of 121 holes cut through a plate, four of its circles drawn a second time on
/// top of themselves. Each of those was two profiles of one region and so two coincident tools:
/// the cut retried with the tool nudged and took fifty times as long. The profiles drawn twice
/// are cut once: no retry, and the plate is the one the 121 circles drawn once make.
#[test]
fn circles_drawn_twice_are_cut_once() {
let cut = |repeats: usize| {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
s.execute("sketch.rectangle.two_point", &json!({"p0": [-35, -35], "p1": [35, 35]})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"distance": 2, "operation": "new"})).unwrap();
s.execute("sketch.create", &json!({"plane": "XY", "offset": 2})).unwrap();
let mut centres = Vec::new();
for i in -7i32..8 {
for j in -8i32..9 {
let (x, y) = (f64::from(j) * 3.45 + if i % 2 != 0 { 1.725 } else { 0.0 }, f64::from(i) * 3.0);
if x.hypot(y) <= 20.7 {
centres.push([x, y]);
}
}
}
assert_eq!(centres.len(), 121);
for c in centres.iter().chain(centres.iter().take(repeats)) {
s.execute("sketch.circle.center", &json!({"center": c, "radius": 1.15})).unwrap();
}
s.execute("sketch.finish", &json!({})).unwrap();
let r = s.execute("solid.extrude", &json!({"operation": "cut", "direction": "negative", "distance": 2, "targets": ["Body1"]})).unwrap();
assert!(r["warning"].is_null(), "{}", r["warning"]);
let state = s.model.state();
let body = &state.body("Body1").unwrap().body;
(body.volume_at(1e-4).unwrap(), body.face_count())
};
let (once, faces_once) = cut(0);
let (twice, faces_twice) = cut(4);
let analytic = 70.0 * 70.0 * 2.0 - 121.0 * std::f64::consts::PI * 1.15 * 1.15 * 2.0;
assert!((once - analytic).abs() < 0.2, "{once} vs {analytic}");
assert!((twice - once).abs() < 1e-9 * once, "{twice} vs {once}");
assert_eq!(faces_twice, faces_once);
}
/// #1985: the STEP export of a small enclosure never finished. The export reads its own file
/// back, and meshing the screw hole's seam cylinder never ended: the boundary walk took
/// projected angles millions of turns apart. The file is written, reads back cleanly, and holds
+13
View File
@@ -28,6 +28,9 @@ mod transfer;
mod occurrence_tests;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.cycle_color", "Cycle Component Color", cycle_color)
.icon("colored_components").doc_only()
.params("component: id or name; saves the next identification colour and enables Component Colors; shared by every instance, separate from appearances"),
CommandSpec::new("component.create", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
.icon("component")
@@ -565,3 +568,13 @@ pub fn to_active_frame(s: &Session, kind: &mut FeatureKind) {
kind.transform_geometry(&inv);
}
}
fn cycle_color(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.cycle_color";
let id = component(s, p, "component", cmd)?;
let next = solidcraft_doc::component_color::next_color(s.doc.component_identification_color(id));
let c = s.doc_mut().components.iter_mut().find(|c| c.id == id).ok_or_else(|| bad(cmd, "choose a component below the design root"))?;
c.identification_color = Some(next);
s.colored_components = Some(());
Ok(json!({"component": id, "color": next}))
}
+1
View File
@@ -257,6 +257,7 @@ pub(super) fn paste(
if let Some(added) = scratch.doc_mut().components.iter_mut().find(|x| x.id == new) {
added.metadata = c.metadata.clone();
added.properties = c.properties.clone();
added.identification_color = c.identification_color;
}
components.insert(*old, new);
if *old != component {
@@ -250,7 +250,13 @@ fn an_edge_origin_is_copied_onto_the_copys_edges() {
name: "Corner".into(),
origin: JointOrigin {
occurrence: 1,
snap: Snap::Edge { pick: Vec3::new(1.0, 2.0, 3.0), edge: edge("F1:top|F1:side"), face: edge("F1:top"), corner: edge("F1:top|F10:end") },
snap: Snap::Edge {
pick: Vec3::new(1.0, 2.0, 3.0),
edge: edge("F1:top|F1:side"),
face: edge("F1:top"),
corner: edge("F1:top|F10:end"),
axis: None,
},
},
}];
destination.assembly.origins.clear();
+38 -3
View File
@@ -113,7 +113,8 @@ fn stored(design: Option<&str>, file: &Path) -> String {
fn resolve(design: Option<&str>, link: &str) -> PathBuf {
let p = Path::new(link);
match base_dir(design) {
Some(d) if p.is_relative() => d.join(p),
// Browser storage keys do not let the OS resolve `..` for us.
Some(d) if p.is_relative() => absolute(&d.join(p)),
_ => p.to_path_buf(),
}
}
@@ -385,7 +386,8 @@ 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());
json!({"component": c, "name": name, "path": l.path, "file": file.display().to_string(), "found": file.is_file()})
let file = file.to_string_lossy();
json!({"component": c, "name": name, "path": l.path, "file": file, "found": solidcraft_io::vfs::is_file(&file)})
})
.collect();
Ok(json!({"links": links}))
@@ -403,7 +405,7 @@ mod tests {
let design = root.join("assembly").join("case.solidcraft");
let design = design.to_string_lossy().into_owned();
assert_eq!(stored(Some(&design), &part), "../parts/plate.solidcraft");
assert_eq!(absolute(&resolve(Some(&design), "../parts/plate.solidcraft")), part);
assert_eq!(resolve(Some(&design), "../parts/plate.solidcraft"), part);
// A design path given relative to the working folder works the same.
assert_eq!(stored(Some("case.solidcraft"), &absolute(Path::new("parts/plate.solidcraft"))), "parts/plate.solidcraft");
// Before the design has a file the path is kept whole; saving it makes it relative.
@@ -594,6 +596,39 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Two arms jointed top face to bottom face, both occurrences of one linked arm design: when
/// the arm is made thicker, its holes moved or it is made longer, Update Links brings the
/// change in and every joint type re-solves onto the moved faces, edges and corners, as it
/// does inside one design (`jointed_arms_stay_together_as_the_arm_changes`).
#[test]
fn jointed_linked_arms_stay_together_through_update_links() {
use crate::cmd::joints::face_tests::{ARM_EDITS, ARM_FORMS, arm_pairs, arm_part, arm_size, arms_apart, joint_arms};
let dir = std::env::temp_dir().join(format!("solidcraft-link-arms-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("arm.solidcraft");
let mut apart = Vec::new();
for form in ARM_FORMS {
let mut part = Session::default();
arm_part(&mut part);
run(&mut part, "file.save", json!({"path": path}));
let mut s = Session::default();
let first = run(&mut s, "component.insert_design", json!({"path": path, "name": "Arm"}))["occurrence"].as_u64().unwrap();
let pairs = arm_pairs(&mut s, first);
joint_arms(&mut s, form, &pairs);
apart.extend(arms_apart(&mut s, "as made", form, &pairs, arm_size(&[])));
for (i, (name, value)) in ARM_EDITS.into_iter().enumerate() {
run(&mut part, "parameters.change", json!({"name": name, "expression": format!("{value} mm")}));
run(&mut part, "file.save", json!({}));
let update = run(&mut s, "component.update_links", json!({}));
assert_eq!(update["updated"], json!(["Arm"]), "{form} {name}: {update}");
assert_eq!(update["warnings"], json!([]), "{form} {name}: {update}");
apart.extend(arms_apart(&mut s, &format!("{name} = {value}"), form, &pairs, arm_size(&ARM_EDITS[..=i])));
}
}
let _ = std::fs::remove_dir_all(&dir);
assert!(apart.is_empty(), "{} joints apart:\n{}", apart.len(), apart.join("\n"));
}
/// A joint made on a link that kept no face names (an older design) names the faces by their
/// place in the file: Update Links says it can't vouch for it, and keeps the names from then on.
#[test]
+4
View File
@@ -104,6 +104,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
_ => skip("the origin axes can't be deleted".into()),
},
Sel::SketchMidpoint { .. } => skip("a midpoint belongs to its sketch edge".into()),
Sel::ConstructionPoint { name } => match s.doc.find_feature(name) {
Some(f) if matches!(f.kind, FeatureKind::ConstructionPoint { .. }) => features.push(f.id),
_ => skip(format!("no construction point named \"{name}\"")),
},
Sel::Body { name } => bodies.push(name.clone()),
Sel::Face { body, point, .. } => match faces.iter_mut().find(|(b, _)| b == body) {
Some((_, ps)) => ps.push(*point),
+35 -13
View File
@@ -518,9 +518,14 @@ fn move_many(s: &mut Session, p: &Value) -> Result<Value> {
sort_group_features(&mut next);
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let active = active_param(p, cmd)?;
let (mut next, ordered, _) = reorder_features(s, next, &moving, slot, false, cmd)?;
let (mut next, ordered, _, checked) = reorder_features(s, next, &moving, slot, false, cmd)?;
let marker = next.marker;
place_marker(s, &mut next, &moving, active, cmd)?;
let checked = checked.filter(|_| next.marker == marker);
if next != *s.doc {
if let Some(model) = checked {
s.model = model;
}
*s.doc_mut() = next;
}
Ok(json!({"features": ordered, "groups": groups, "group": target, "position": slot}))
@@ -1175,8 +1180,15 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let list: Vec<String> = broken.iter().map(|(n, e)| format!("{n} ({e})")).collect();
return Err(bad(cmd, format!("moving {name} there breaks {}", list.join("; "))));
}
let marker = next.marker;
place_marker(s, &mut next, &[id], active, cmd)?;
if next != *s.doc {
// The check evaluated the new order: keep its results, so setting the design doesn't
// evaluate the moved part of the timeline a second time (#6186). Placing the marker
// elsewhere makes them stale.
if next.marker == marker {
s.model = after;
}
*s.doc_mut() = next;
}
let mut out = json!({"feature": id, "position": to});
@@ -1232,9 +1244,15 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
let slot = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?;
let active = active_param(p, cmd)?;
let allow = allow_errors(p, cmd)?;
let (mut next, ordered, broken) = reorder_features(s, (*s.doc).clone(), &ids, slot, allow, cmd)?;
let (mut next, ordered, broken, checked) = reorder_features(s, (*s.doc).clone(), &ids, slot, allow, cmd)?;
let marker = next.marker;
place_marker(s, &mut next, &ordered, active, cmd)?;
// The checked results stand only when placing the marker left the design as they were made for.
let checked = checked.filter(|_| next.marker == marker);
if next != *s.doc {
if let Some(model) = checked {
s.model = model;
}
*s.doc_mut() = next;
}
let mut out = json!({"features": ordered, "position": slot.min(s.doc.features.len())});
@@ -1244,6 +1262,8 @@ fn reorder_many(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// The reordered design, the moved features in order, and the model the move was checked on
/// (none when the order didn't change), for the caller to keep (#6186).
fn reorder_features(
s: &Session,
mut next: solidcraft_doc::Document,
@@ -1251,7 +1271,7 @@ fn reorder_features(
slot: usize,
allow_errors: bool,
cmd: &str,
) -> Result<(solidcraft_doc::Document, Vec<u64>, Vec<(String, String)>)> {
) -> Result<(solidcraft_doc::Document, Vec<u64>, Vec<(String, String)>, Option<solidcraft_doc::Model>)> {
let n = next.features.len();
let slot = slot.min(n);
let selected: std::collections::BTreeSet<u64> = ids.iter().copied().collect();
@@ -1276,17 +1296,19 @@ fn reorder_features(
next.features.splice(insert_at..insert_at, moving);
sort_group_features(&mut next);
require_contiguous_groups(&next, cmd)?;
let mut broken = Vec::new();
if !unchanged {
let mut after = solidcraft_doc::Model::new();
after.evaluate(&next);
broken = newly_broken(&s.model, &after);
if !broken.is_empty() && !allow_errors {
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
}
if unchanged {
return Ok((next, ordered, Vec::new(), None));
}
Ok((next, ordered, broken))
// From the session's own results: the features before the first one moved are kept, not
// evaluated again from an empty model (#6186).
let mut after = s.model.clone();
after.evaluate(&next);
let broken = newly_broken(&s.model, &after);
if !broken.is_empty() && !allow_errors {
let list: Vec<String> = broken.iter().map(|(name, error)| format!("{name} ({error})")).collect();
return Err(bad(cmd, format!("moving the selected features there breaks {}", list.join("; "))));
}
Ok((next, ordered, broken, Some(after)))
}
fn input_keys(kind: &FeatureKind) -> Vec<(String, solidcraft_doc::expr::Kind)> {
+24
View File
@@ -1009,3 +1009,27 @@ fn renaming_a_plane_keeps_its_projections_canvases_and_hidden_state() {
assert_eq!(s.visibility.hidden_origin, ["Rail"]);
assert_eq!(plane_link(&s), "Ledge");
}
/// #6186: a reorder evaluates the design once. The model the move was checked on is the one the
/// session keeps, so setting the reordered design finds every result ready: nothing is
/// recomputed or taken from the cache again. The same for `timeline.reorder_many`, whose check
/// starts from the session's results rather than an empty model.
#[test]
fn a_reorder_keeps_the_model_it_was_checked_on() {
let mut s = Session::default();
for i in 0..4 {
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5 + i, "corner": [i * 10, 0, 0], "operation": "new"}));
}
let bodies = |s: &Session| s.model.state().bodies.len();
run(&mut s, "timeline.reorder", json!({"feature": "Box2", "position": 0}));
assert_eq!((s.model.last_recomputed, s.model.last_restored), (0, 0), "the checked model was kept");
assert_eq!(s.doc.features.first().map(|f| f.name.as_str()), Some("Box2"));
assert_eq!(bodies(&s), 4);
run(&mut s, "timeline.reorder_many", json!({"features": ["Box4"], "position": 2}));
assert_eq!((s.model.last_recomputed, s.model.last_restored), (0, 0), "the checked model was kept");
assert_eq!(s.doc.features.get(2).map(|f| f.name.as_str()), Some("Box4"));
// The kept model is the design's: evaluating it from scratch gives the same bodies.
let mut fresh = solidcraft_doc::Model::new();
fresh.evaluate(&s.doc);
assert_eq!(fresh.state().bodies.len(), bodies(&s));
}
+16
View File
@@ -106,6 +106,22 @@ fn inside_hull(sorted: &[(f64, f64)], q: (f64, f64)) -> bool {
hull.len() >= 3 && (0..hull.len()).all(|i| cross(hull[i], hull[(i + 1) % hull.len()], q) >= -eps)
}
/// The outward normal of the planar face at the world point `p`: on the model as the view
/// places it (moved components where they are shown).
pub(super) fn face_normal_world(s: &Session, p: Vec3) -> Option<Vec3> {
let (m, f, d, _) = face_at_state(&s.world_state(), p, None).ok().flatten()?;
let tol = (m.bounds().diagonal() * 1e-3).max(1e-3);
if d > tol * 10.0 {
return None;
}
m.triangles
.iter()
.zip(&m.tri_face)
.filter(|(_, tf)| **tf == f)
.filter_map(|(t, _)| m.tri(t))
.find_map(|[a, b, c]| (b - a).cross(c - a).normalized())
}
/// Is the body face at `p` planar (`None`: no face there)?
pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
let (m, f, d, _) = face_at(s, p)?;
+2 -2
View File
@@ -19,7 +19,7 @@ const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
const EXTRUDE_CONTRACT: TypedContract<ExtrudeParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", EXTRUDE_CONTRACT, extrude))
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); rails?: [{sketch?: sketch (default path_sketch), curves: [curve ids in order]}] (guide rails, one or two, each meeting every plane square to the path once along it, from the end nearest the path's start: one rail turns the profile so its point where the rail starts stays on the rail and scales it with the rail's distance from the path; two rails carry it between them, its points where they start staying on both; rails take the default orientation, no twist and no scale, and a path without sharp corners); rail_fit?: scale (default: the profile grows and shrinks evenly) | stretch (only across to the rail, between the rails); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2…50, in order; matching hole counts, no holes at an apex); rails?: [{sketch, curves: [ids in order]}] (one or two guide rails from the first section to the last, through every section's outline: the loft follows them between the sections; not with sections that have holes); rail_fit?: scale|stretch (default scale: with one rail the sections grow evenly with the rail's distance from their middle, with two with the distance between the rails; stretch: only that way); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", BOX_CONTRACT, prim_box))
CommandSpec::new("solid.box", "Box", crate::checked!("solid.box", prim_box))
.at("SOLID", "CREATE")
.icon("box")
.contract(&BOX_CONTRACT)
+20 -2
View File
@@ -410,6 +410,17 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no body `{b}`")));
}
}
// Picks and snaps are where the view shows them (the world); the feature moves the bodies
// in their component's frame, so both ends go into the first moved body's frame. A body
// picked in another placed component (aligning two imported parts) is found where it
// is shown, not where its own frame has it.
let frame = s
.placed_document()
.body_frames_with_source(&s.model.state())
.into_iter()
.find(|(world, own, ..)| *world == bodies[0] || *own == bodies[0])
.map(|(.., m)| m);
let inverse = frame.and_then(|m| solidcraft_doc::mat_inverse(&m)).unwrap_or(solidcraft_doc::IDENTITY);
// A side is a plane (`from_plane`/`to_plane`: XY|XZ|YZ, a construction or offset plane, or
// {origin, normal}), kept as a plane so the bodies follow it when it moves; or a point and, for
// a face, its normal.
@@ -417,7 +428,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
p.get(key).map(|_| super::features::plane_param(s, p.get(key), cmd)).transpose()
};
let (from_plane, to_plane) = (plane(s, "from_plane")?, plane(s, "to_plane")?);
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
let world_pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
if plane.is_some() {
if p.get(pk).is_some() || p.get(fk).is_some() {
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
@@ -426,7 +437,7 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
return Ok((Vec3::ZERO, None));
}
if let Some(fp) = p.get(fk).and_then(vec3) {
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
let n = super::face::face_normal_world(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// The origin point.
@@ -444,6 +455,13 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
};
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
let (at, n) = world_pick(s, pk, fk, plane)?;
if plane.is_some() {
return Ok((at, n));
}
Ok((solidcraft_doc::apply_point(&inverse, at), n.and_then(|n| solidcraft_doc::apply_vector(&inverse, n).normalized())))
};
let (from, from_normal) = pick(s, "from", "from_face", &from_plane)?;
let (to, to_normal) = pick(s, "to", "to_face", &to_plane)?;
// Faces turn only to meet another face or a plane; a plane's normal is read when evaluated.
@@ -2046,3 +2046,64 @@ fn an_imported_assembly_with_a_repeated_part_moves_as_one() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A 20 × 30 × 8 plate made in its own component and stood up by its placement (turned
/// `angle` degrees about X), the way an imported part set upright is: in the world it lies
/// flat at `x0`..`x0 + 20`, y 0..30, z 0..8. The design is active again afterwards.
fn placed_plate(s: &mut Session, name: &str, x0: f64, angle: f64) {
let made = run(s, "component.create", json!({"name": name, "grounded": false}));
// Turned +90° about X a point goes (x, y, z) → (x, −z, y); turned −90°, (x, z, −y).
let corner = if angle > 0.0 { json!([x0, 0, -30]) } else { json!([x0, -8, 0]) };
let (width, height) = (8, 30);
run(s, "solid.box", json!({"length": 20, "width": width, "height": height, "corner": corner, "body_name": name}));
run(s, "occurrence.move", json!({"occurrence": made["occurrence"], "axis": [1, 0, 0], "angle": format!("{angle} deg")}));
run(s, "component.activate", json!({"component": 0}));
let b = world_bounds(s, name);
for (got, want) in [(b.min.x, x0), (b.max.x, x0 + 20.0), (b.min.y, 0.0), (b.max.y, 30.0), (b.min.z, 0.0), (b.max.z, 8.0)] {
assert!((got - want).abs() < 1e-6, "{name} placed at {b:?}");
}
}
fn world_bounds(s: &Session, name: &str) -> solidcraft_geom::Aabb3 {
s.world_state().body(name).unwrap_or_else(|| panic!("no {name}")).mesh().bounds()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// Move's vector, axis and pivot are where the view shows them, whatever frame the moved
/// body's component has: a plate stood up by its placement and moved up goes up.
#[test]
fn a_move_goes_the_way_it_is_shown_on_a_placed_component() {
let mut s = Session::default();
placed_plate(&mut s, "Plate", 0.0, 90.0);
run(&mut s, "solid.move", json!({"bodies": ["Plate"], "translate": [0, 0, 10]}));
let b = world_bounds(&s, "Plate");
assert!(near(b.min.z, 10.0) && near(b.max.z, 18.0) && near(b.min.y, 0.0) && near(b.max.y, 30.0), "moved up: {b:?}");
// A quarter turn about the world's Z through the origin: (x, y) → (−y, x).
run(&mut s, "solid.move", json!({"bodies": ["Plate"], "axis": [0, 0, 1], "angle": 90}));
let b = world_bounds(&s, "Plate");
assert!(near(b.min.x, -30.0) && near(b.max.x, 0.0) && near(b.min.y, 0.0) && near(b.max.y, 20.0) && near(b.min.z, 10.0), "turned: {b:?}");
}
/// Align between two parts placed in different frames: both ends are taken where they are
/// shown, so the plate lands on the other one rather than at right angles to it.
#[test]
fn align_takes_both_ends_where_they_are_shown_across_placed_components() {
let mut s = Session::default();
placed_plate(&mut s, "A", 0.0, 90.0);
placed_plate(&mut s, "B", 50.0, -90.0);
// Corner to corner: a plain move.
run(&mut s, "solid.align", json!({"bodies": ["A"], "from": [0, 0, 8], "to": [50, 0, 8]}));
let a = world_bounds(&s, "A");
assert!(near(a.min.x, 50.0) && near(a.max.x, 70.0) && near(a.min.z, 0.0) && near(a.max.z, 8.0), "{a:?}");
// Snapped corners, then the top face onto B's top face: A turns over and sits on B.
run(&mut s, "solid.align", json!({"bodies": ["A"], "from": {"snap": "vertex", "at": [50, 0, 8]}, "to": {"snap": "vertex", "at": [70, 30, 8]}}));
let a = world_bounds(&s, "A");
assert!(near(a.min.x, 70.0) && near(a.min.y, 30.0) && near(a.max.z, 8.0), "snapped: {a:?}");
run(&mut s, "solid.align", json!({"bodies": ["A"], "from_face": [80, 45, 8], "to_face": [60, 15, 8]}));
let a = world_bounds(&s, "A");
assert!(near(a.min.z, 8.0) && near(a.max.z, 16.0), "on B's top, not across it: {a:?}");
assert!(near(0.5 * (a.min.x + a.max.x), 60.0) && near(0.5 * (a.min.y + a.max.y), 15.0), "centred on the picked point: {a:?}");
}
+43 -2
View File
@@ -1,4 +1,4 @@
//! Documents and files: new, open (designs and STEP), insert STEP, save, export.
//! Documents and files: new, open designs, CAD files and drawings, insert STEP, save, export.
#[path = "file_thumbnail.rs"]
mod thumbnail;
@@ -20,7 +20,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
.params("path: .solidcraft design, .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
@@ -260,6 +260,18 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
if !solidcraft_io::is_step_path(path) && !solidcraft_io::is_gltf_path(path) && p.get("up").is_some_and(|v| !v.is_null()) {
return Err(bad("doc.open", "`up` applies to STEP, IGES and glTF files only"));
}
if let Some(command) = super::drawing_import_command(path) {
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
let mut fresh = Session::new(Document::new(&stem));
let mut out = fresh.execute(command, &json!({"path": path}))?;
fresh.undo.clear();
s.replace_design(fresh);
out["path"] = json!(path);
out["name"] = json!(s.doc.name);
out["features"] = json!(s.doc.features.len());
out["errors"] = json!(s.model.results.iter().filter(|r| r.error.is_some()).count());
return Ok(out);
}
if solidcraft_io::is_step_path(path) || solidcraft_io::is_mesh_path(path) {
// A new, unsaved design holding the file's bodies.
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
@@ -763,6 +775,35 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn opening_drawing_files_creates_an_unsaved_xy_sketch() {
let dir = name_dir("drawing-open");
let dxf = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n10\n21\n0\n0\nLINE\n10\n10\n20\n0\n11\n10\n21\n10\n0\nARC\n10\n0\n20\n0\n40\n5\n50\n0\n51\n90\n0\nENDSEC\n0\nEOF\n";
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0 L10 0 L10 10"/><circle cx="5" cy="5" r="2"/></svg>"#;
for (file, text) in [("outline.DXF", dxf), ("outline.SVG", svg)] {
let path = dir.join(file);
std::fs::write(&path, text).unwrap();
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane":"YZ"})).unwrap();
let result = s.execute("doc.open", &json!({"path":path})).unwrap();
assert_eq!(result["errors"], 0);
assert_eq!(s.doc.features.len(), 1);
let id = s.active_sketch.unwrap();
let sketch = s.doc.sketch(id).unwrap();
assert_eq!(s.model.state().sketch(id).unwrap().plane, solidcraft_geom::Plane::XY);
assert_eq!(sketch.curves.len(), 3);
assert_eq!(s.doc.name, "outline");
assert!(s.path.is_none(), "Save must ask for a design path");
assert!(s.is_dirty());
assert!(s.undo.is_empty());
let before = s.doc.clone();
std::fs::write(&path, "invalid drawing").unwrap();
assert!(s.execute("doc.open", &json!({"path":path})).is_err());
assert_eq!(s.doc, before, "a failed open preserves the current design");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// #3044: a save run on a scratch copy of the session (on a worker, so the window keeps
/// drawing) and taken up with `adopt_save` leaves the session as saving it directly does:
/// the design named after its first file, its file, and nothing unsaved; undo keeps the name.
+17 -1
View File
@@ -8,6 +8,9 @@ use crate::params::{bad, bool_, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.colored_components", "Enable Component Colors", colored_components)
.at("SOLID", "INSPECT").icon("colored_components").noundo()
.params("enabled?: bool (omitted: toggle); temporary component colours, without changing appearances"),
CommandSpec::new("inspect.measure", "Measure", measure)
.at("SOLID", "INSPECT")
.icon("measure")
@@ -44,7 +47,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.doc_only()
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", DESCRIBE_CONTRACT, describe))
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", describe))
.icon("list")
.noundo()
.doc_only()
@@ -724,3 +727,16 @@ fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
.collect::<Result<Vec<_>>>()?;
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
}
fn colored_components(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("enabled").is_some_and(|v| !v.is_boolean()) {
return Err(bad("inspect.colored_components", "enabled must be a boolean"));
}
let enabled = bool_(p, "enabled").unwrap_or(s.colored_components.is_none());
if enabled && s.colored_components.is_none() {
s.colored_components = Some(());
} else if !enabled {
s.colored_components = None;
}
Ok(json!({"enabled": enabled}))
}
+56 -3
View File
@@ -20,7 +20,7 @@ mod inspect;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", joint_origin).at("SOLID", "CREATE").icon("joint_origin").params(
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size) | point: [x,y,z], z?: [x,y,z]; body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
"name?, occurrence?: id|name (default: from the pick, or the named face's component), face: [x,y,z] | \"<name>\" (centre of a planar face) | circle: [x,y,z] | \"<name>\" (centre of a round end: a point on its wall or rim, the end's centre, or the round face's name as model.faces lists it) | edge: [x,y,z] (a point on an edge of a planar face: the edge's middle, z out of the face, y into it; on?: [x,y,z] a point on the face (default: the larger planar face beside the edge); corner?: [x,y,z] near one end: the corner there, x and y along its two edges into the face; found again by name, so it follows the face as it changes size; z?: [x,y,z] the frame's z instead of the face's normal, along the edge when it runs that way, x chosen from z: an edge or corner picked as it is, kept as a point where it can't be found by name) | point: [x,y,z], z?: [x,y,z]; a circle's z?: [x,y,z] (a round edge's centre with the way out of it: its end by name when that is the frame it shows, else a point there); body?: the named face's body; end?: \"<name>\" (a circle's end: the face across its rim there; default the end nearer the pick); x?: [x,y,z] (a direction, world) | \"<face name>\" | {face: [x,y,z]} (the frame's x: a direction, a plane's normal, or the way to another hole's axis; keeps a round origin's turn; a point's x is a direction, laid square to its z, default: chosen from z alone). Faces are kept by name: the origin follows its face, and a circle its end, when the design changes",
),
CommandSpec::new("joint.create", "Joint", joint).at("SOLID", "ASSEMBLE").icon("joint").key("J").params(
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball, or a relation: parallel|perpendicular|angle|point_on_plane|point_on_line|line_on_plane \
@@ -384,7 +384,20 @@ pub(crate) fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<
}
"edge" => {
let (on, corner) = (v.get("on").and_then(vec3).map(local), v.get("corner").and_then(vec3).map(local));
joints::edge_snap(&s.doc, &st, component, local(world), on, corner).map_err(|e| bad(cmd, e.to_string()))?
let snap = joints::edge_snap(&s.doc, &st, component, local(world), on, corner);
match given_z(v, &local_dir, cmd)? {
None => snap.map_err(|e| bad(cmd, e.to_string()))?,
// An edge or a corner picked as it is (z given): it follows the edge by name with
// that z, or, where it can't be (no planar face beside it, no names), stays a
// point there as before.
Some(z) => {
let at = corner.unwrap_or(local(world));
let point = Snap::Point { point: at, z: Some(z), x: None };
let followed = snap.ok().map(|snap| joints::edge_axis(&s.doc, &st, component, snap, z));
let o = followed.map(|snap| JointOrigin { occurrence, snap }).filter(|o| same_frame(s, o, at, z));
return Ok(o.unwrap_or(JointOrigin { occurrence, snap: point }));
}
}
}
"point" => {
let z = v.get("z").and_then(vec3).map(local_dir);
@@ -407,6 +420,28 @@ pub(crate) fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<
}
};
let o = JointOrigin { occurrence, snap };
// A round edge's centre with the z it was picked with: its end, by name, where that is the
// frame it shows; else a point there as before.
if what == "circle"
&& let Some(z) = given_z(v, &local_dir, cmd)?
{
let at = local(world);
// The turn the point gave, kept.
let point = Snap::Point { point: at, z: Some(z), x: None };
let px =
joints::resolve_origin(&s.doc, &st, &JointOrigin { occurrence, snap: point.clone() }).ok().map(|m| Vec3::new(m[0][0], m[0][1], m[0][2]));
let turn = |snap: Snap| match (snap, px) {
(Snap::CircleCenter { pick, face, end, x: None }, Some(x)) => Snap::CircleCenter { pick, face, end, x: Some(XRef::Dir(x)) },
(snap, _) => snap,
};
let by_wall = settle(s, JointOrigin { occurrence, snap: turn(o.snap.clone()) }, v, cmd).ok();
let by_centre = || {
let snap = joints::round_end_at(&s.doc, &st, component, at)?;
settle(s, JointOrigin { occurrence, snap: turn(snap) }, v, cmd).ok()
};
let found = by_wall.filter(|o| same_frame(s, o, at, z)).or_else(|| by_centre().filter(|o| same_frame(s, o, at, z)));
return Ok(found.unwrap_or(JointOrigin { occurrence, snap: point }));
}
match settle(s, o, v, cmd) {
Ok(o) => Ok(o),
// A circle given by its centre, where no face lies (#393): that end of the cylinder, by
@@ -422,6 +457,24 @@ pub(crate) fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<
}
}
/// An origin's `z` (world), in its component's coordinates.
fn given_z(v: &Value, local_dir: &dyn Fn(Vec3) -> Vec3, cmd: &str) -> Result<Option<Vec3>> {
match v.get("z") {
None | Some(Value::Null) => Ok(None),
Some(z) => {
vec3(z).filter(|z| z.is_finite() && z.len() > 1e-9).map(|z| Some(local_dir(z))).ok_or_else(|| bad(cmd, "`z` is a direction [x, y, z]"))
}
}
}
/// Whether origin `o` resolves, now, to the frame at `at` with z along `z` (component coordinates)
/// that its pick showed.
fn same_frame(s: &Session, o: &JointOrigin, at: Vec3, z: Vec3) -> bool {
let Ok(m) = joints::resolve_origin(&s.doc, &s.model.state(), o) else { return false };
let (o, oz) = (Vec3::new(m[3][0], m[3][1], m[3][2]), Vec3::new(m[2][0], m[2][1], m[2][2]));
o.dist(at) <= 1e-6 * at.len().max(100.0) && z.normalized().is_some_and(|z| oz.dot(z) > 1.0 - 1e-9)
}
/// The longest face name a joint origin takes. A face that cuts split and merged keeps the names
/// of the faces around it (`<name>#[<name>,…]`): several hundred characters on a part a text or
/// a pattern of holes was cut through.
@@ -1462,7 +1515,7 @@ mod edge_tests;
mod exprs_tests;
#[cfg(test)]
#[path = "joints_face_tests.rs"]
mod face_tests;
pub(crate) mod face_tests;
#[cfg(test)]
#[path = "joint_frames_tests.rs"]
mod frames_tests;
+174
View File
@@ -400,3 +400,177 @@ fn a_face_centre_stays_put_when_a_hole_in_its_face_moves_or_goes() {
}
}
}
// ---- Two arms jointed together, the arm part edited: every joint type keeps its origins
// together, for each way the joint dialog picks an origin, inside one design and through a link.
/// The arm: a plate `L` × 20 × `T` (100 × 20 × 5 to start) along +X from the origin, with a Ø 6
/// hole through it at x = `HX` (10) and another 10 mm in from its far end. Made at the active
/// component.
pub(crate) fn arm_part(s: &mut Session) {
for (name, e) in [("T", "5 mm"), ("L", "100 mm"), ("HX", "10 mm")] {
run(s, "parameters.add", json!({"name": name, "expression": e}));
}
run(s, "solid.box", json!({"length": "L", "width": 20, "height": "T", "corner": [0, -10, 0], "body_name": "Arm"}));
run(s, "solid.cylinder", json!({"diameter": 6, "height": 100, "base": ["HX", 0, -50], "operation": "cut"}));
run(s, "solid.cylinder", json!({"diameter": 6, "height": 100, "base": ["L - 10 mm", 0, -50], "operation": "cut"}));
}
/// The arm's edits, (parameter, value), applied in turn: thicker (A's top face moves along the
/// joint's axis, as does B's bottom hole rim relative to A), A's hole moves sideways, and the arm
/// grows so its far end (B's hole, edge and corner) moves sideways.
pub(crate) const ARM_EDITS: [(&str, f64); 4] = [("T", 12.0), ("HX", 15.0), ("L", 140.0), ("T", 3.0)];
/// The joint types tried (each between its own pair of arms) and as-built.
pub(crate) const ARM_KINDS: [&str; 9] = ["rigid", "revolute", "slider", "cylindrical", "pin_slot", "planar", "ball", "point_on_plane", "as_built"];
/// How the joint dialog picks an origin on arm A's top (a) and arm B's bottom (b), as world
/// params, for the arms at A (0, y, 0) and B (0, y + 60, 0) in the arm's starting size.
pub(crate) fn arm_origins(form: &str, y: f64) -> (serde_json::Value, serde_json::Value) {
let b = y + 60.0;
match form {
// A hole's rim (a round edge): its centre and the way out of the material.
"rim" => (json!({"circle": [10, y, 5], "z": [0, 0, 1]}), json!({"circle": [90, b, 0], "z": [0, 0, -1]})),
// A hole's wall.
"wall" => (json!({"circle": [13, y, 4]}), json!({"circle": [93, b, 1]})),
// The plate's top and bottom faces.
"face" => (json!({"face": [50, y + 5.0, 5]}), json!({"face": [50, b + 5.0, 0]})),
// A straight edge: its middle, z along it.
"edge" => (json!({"edge": [50, y + 10.0, 5], "z": [1, 0, 0]}), json!({"edge": [50, b + 10.0, 0], "z": [1, 0, 0]})),
// A corner (a vertex), z along the part's +Z.
_ => (
json!({"edge": [100, y + 10.0, 5], "corner": [100, y + 10.0, 5], "z": [0, 0, 1]}),
json!({"edge": [100, b + 10.0, 0], "corner": [100, b + 10.0, 0], "z": [0, 0, 1]}),
),
}
}
pub(crate) const ARM_FORMS: [&str; 5] = ["rim", "wall", "face", "edge", "corner"];
/// One joint of each of [`ARM_KINDS`] between arm pairs `pairs` (A, B occurrences), its origins
/// picked as `form` picks them.
pub(crate) fn joint_arms(s: &mut Session, form: &str, pairs: &[(u64, u64)]) {
for (k, (kind, (a, b))) in ARM_KINDS.iter().zip(pairs).enumerate() {
let (mut pa, mut pb) = arm_origins(form, 120.0 * k as f64);
pa["occurrence"] = json!(a);
pb["occurrence"] = json!(b);
let name = format!("{form}-{kind}");
let r = if *kind == "as_built" {
run(s, "joint.as_built", json!({"type": "rigid", "name": name, "a": a, "b": b, "at": pa}))
} else {
run(s, "joint.create", json!({"type": kind, "name": name, "a": pa, "b": pb}))
};
assert_eq!(r["errors"], json!([]), "{name}: {r}");
}
}
/// The arm's size as edited so far: T, HX, L (mm).
pub(crate) fn arm_size(edits: &[(&str, f64)]) -> (f64, f64, f64) {
let at = |n: &str, d: f64| edits.iter().rev().find(|(m, _)| *m == n).map_or(d, |(_, v)| *v);
(at("T", 5.0), at("HX", 10.0), at("L", 100.0))
}
/// Where `form` picked A's and B's origins, on the arm as it is now (T, HX, L): component
/// coordinates of the geometry itself (the hole's rim, the plate's top face's centre, …).
fn arm_spots(form: &str, (t, hx, l): (f64, f64, f64)) -> (Vec3, Vec3) {
match form {
"rim" | "wall" => (Vec3::new(hx, 0.0, t), Vec3::new(l - 10.0, 0.0, 0.0)),
"face" => (Vec3::new(l / 2.0, 0.0, t), Vec3::new(l / 2.0, 0.0, 0.0)),
"edge" => (Vec3::new(l / 2.0, 10.0, t), Vec3::new(l / 2.0, 10.0, 0.0)),
_ => (Vec3::new(l, 10.0, t), Vec3::new(l, 10.0, 0.0)),
}
}
/// Each joint between `pairs` checked against the arm's geometry as it is now (`size`): A's
/// origin is on the rim, face, edge or corner it was picked on; B's is on its own and sits on
/// A's, z against z (point-on-plane: on A's plane; as-built: B keeps its place from A's
/// origin). The joints that aren't, with why.
pub(crate) fn arms_apart(s: &mut Session, edit: &str, form: &str, pairs: &[(u64, u64)], size: (f64, f64, f64)) -> Vec<String> {
let list = run(s, "joint.list", json!({}));
let mut out: Vec<String> = list["errors"].as_array().into_iter().flatten().map(|e| format!("{edit}: {} {}", e["name"], e["why"])).collect();
let v = |f: &serde_json::Value, k: &str| {
Vec3::new(f[k][0].as_f64().unwrap_or(f64::NAN), f[k][1].as_f64().unwrap_or(f64::NAN), f[k][2].as_f64().unwrap_or(f64::NAN))
};
let world = |o: u64, p: Vec3| solidcraft_doc::apply_point(&solidcraft_doc::joints::occurrence_world(&s.doc, o), p);
let (sa, sb) = arm_spots(form, size);
let (sa0, _) = arm_spots(form, arm_size(&[]));
for (kind, (a, b)) in ARM_KINDS.iter().zip(pairs) {
let name = format!("{form}-{kind}");
let Some(j) = list["joints"].as_array().into_iter().flatten().find(|j| j["name"] == name.as_str()) else {
out.push(format!("{edit}: {name} is gone"));
continue;
};
let (oa, ob, za, zb) = (v(&j["frame_a"], "origin"), v(&j["frame_b"], "origin"), v(&j["frame_a"], "z"), v(&j["frame_b"], "z"));
let (at_a, at_b) = (world(*a, sa), world(*b, sb));
let mut why = Vec::new();
if oa.dist(at_a) > 1e-6 {
why.push(format!("A's origin at {oa:?}, its geometry at {at_a:?}"));
}
match *kind {
"as_built" => {
// B was made 60 mm along Y from A, the origin wherever it was on A.
let want = world(*a, Vec3::new(0.0, 60.0, 0.0) + sa - sa0);
if world(*b, Vec3::ZERO).dist(want) > 1e-6 || oa.dist(ob) > 1e-6 {
why.push(format!("B at {:?}, not {want:?}", world(*b, Vec3::ZERO)));
}
}
"point_on_plane" => {
if (at_b - at_a).dot(za).abs() > 1e-6 {
why.push(format!("B's spot {at_b:?} is off A's plane at {at_a:?} z {za:?}"));
}
}
_ => {
if ob.dist(at_b) > 1e-6 || at_a.dist(at_b) > 1e-6 || za.dot(zb) > -1.0 + 1e-9 {
why.push(format!("B's origin at {ob:?} z {zb:?}, its geometry at {at_b:?}, A's at {at_a:?} z {za:?}"));
}
}
}
if !why.is_empty() {
out.push(format!("{edit}: {name}: {}", why.join("; ")));
}
}
out
}
/// The A, B arm occurrence pairs (one per joint type), A grounded, from the arm's first
/// occurrence `first` (at the origin).
pub(crate) fn arm_pairs(s: &mut Session, first: u64) -> Vec<(u64, u64)> {
run(s, "occurrence.ground", json!({"occurrence": first, "grounded": true}));
(0..ARM_KINDS.len())
.map(|k| {
let y = 120.0 * k as f64;
let a = if k == 0 {
first
} else {
run(s, "occurrence.copy", json!({"occurrence": first, "translate": [0, y, 0]}))["occurrence"].as_u64().unwrap_or(0)
};
run(s, "occurrence.ground", json!({"occurrence": a, "grounded": true}));
let b = run(s, "occurrence.copy", json!({"occurrence": first, "translate": [0, y + 60.0, 0]}))["occurrence"].as_u64().unwrap_or(0);
(a, b)
})
.collect()
}
/// Two arms jointed top face to bottom face, the arm's thickness, holes and length changed in the
/// design: every joint re-solves onto the moved faces, edges and corners (the joint dialog kept
/// a round edge, a straight one and a corner as points where they were picked, so a thicker
/// arm overlapped the one jointed to it).
#[test]
fn jointed_arms_stay_together_as_the_arm_changes() {
let mut apart = Vec::new();
for form in ARM_FORMS {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Arm"}));
arm_part(&mut s);
run(&mut s, "component.activate", json!({"component": "root"}));
let first = occ(&s, "Arm");
let pairs = arm_pairs(&mut s, first);
joint_arms(&mut s, form, &pairs);
apart.extend(arms_apart(&mut s, "as made", form, &pairs, arm_size(&[])));
for (i, (name, value)) in ARM_EDITS.into_iter().enumerate() {
run(&mut s, "parameters.change", json!({"name": name, "expression": format!("{value} mm")}));
apart.extend(arms_apart(&mut s, &format!("{name} = {value}"), form, &pairs, arm_size(&ARM_EDITS[..=i])));
}
}
assert!(apart.is_empty(), "{} joints apart:\n{}", apart.len(), apart.join("\n"));
}
+7 -2
View File
@@ -39,6 +39,7 @@ pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, edge_ref_form, sketch_key};
pub use features::{auto_operation, extrude_reach};
pub use sketch_import::drawing_import_command;
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;
@@ -214,12 +215,16 @@ impl CommandSpec {
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let (label, icon) = match self.id {
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
_ => (self.label, self.icon),
};
CommandInfo {
id: self.id,
label: self.label,
label,
tab: self.tab,
panel: self.panel,
icon: self.icon,
icon,
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
+5 -1
View File
@@ -58,12 +58,16 @@ pub(super) fn source(s: &Session, p: &Value, cmd: &str) -> Result<MotionTrace> {
let placed = placed_world(s, &occurrence)?;
let inverse = solidcraft_doc::mat_inverse(&placed).ok_or_else(|| bad(cmd, "invalid occurrence placement"))?;
let canonical = joints::occurrence_world(&s.doc, id);
for key in ["face", "circle", "edge", "point"] {
for key in ["face", "circle", "edge", "point", "corner"] {
if let Some(at) = reference.get(key).and_then(crate::params::vec3) {
let at = apply_point(&canonical, apply_point(&inverse, at));
reference[key] = json!([at.x, at.y, at.z]);
}
}
if let Some(z) = reference.get("z").and_then(crate::params::vec3) {
let z = solidcraft_doc::apply_vector(&canonical, solidcraft_doc::apply_vector(&inverse, z));
reference["z"] = json!([z.x, z.y, z.z]);
}
let o = super::super::joints::origin_param(s, Some(&reference), cmd)?;
if o.occurrence != occurrence.last().copied().unwrap_or(0) {
return Err(bad(cmd, "point_ref must belong to the traced occurrence"));
+15 -3
View File
@@ -120,7 +120,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:<line>\" (held at the line's midpoint), \"on:<curve>:x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs] (a line midpoint is \"mid:line_id\"), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other); dimension their centre points for the distance between centres. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
"entities: [refs] (a line midpoint is \"mid:line_id\"), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other); dimension their centre points for the distance between centres. An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -283,8 +283,8 @@ pub(super) fn edit_solved<T>(
) -> Result<(T, Option<SolveReport>)> {
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
if sk.patterns.iter().any(|p| p.count_expression.is_some()) {
doc.apply_pattern_quantities(&doc.param_values().0, &mut sk)?;
if sk.patterns.iter().any(|p| p.count_expression.is_some() || !p.expressions.is_empty()) {
doc.apply_pattern_values(&doc.param_values().0, &mut sk)?;
}
// The next frame of a live gesture on this sketch, nothing else run since the last one: its
// links are as that frame projected them, from a model that hasn't changed (#3222).
@@ -2125,6 +2125,13 @@ fn symmetric_about_point(sk: &mut Sketch, a: usize, b: usize, c: usize, cmd: &st
add_c(sk, ConstraintKind::Midpoint { p: c, l })
}
/// Is point `q` an end of arc `c`: one of its end points, or a point where one of them is?
fn arc_end(sk: &Sketch, c: usize, q: usize) -> bool {
let Some(CurveKind::Arc { a, b, .. }) = sk.curves.get(c).map(|x| &x.kind) else { return false };
let at = |i: usize| sk.point(i);
*a == q || *b == q || at(q).is_some_and(|pq| [*a, *b].iter().any(|e| at(*e).is_some_and(|pe| pe.dist(pq) < 1e-9)))
}
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.symmetry";
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
@@ -2439,6 +2446,11 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
}
}
// An arc and one of its own end points (or a point on that end): its length (#754).
((Some(q), None), Some((None, Some(c)))) | ((None, Some(c)), Some((Some(q), None))) if arc_end(sk, c, q) => {
let len = arc_len(sk, c).ok_or_else(|| bad(cmd, "arc length needs an arc"))?;
(ConstraintKind::ArcLength { c, value: len }, len)
}
((None, Some(a)), Some((None, Some(b)))) if ty == "overall_length" => {
let current = sk
.center(a)
@@ -1339,3 +1339,29 @@ fn conflicting_component_dimension_is_still_refused() {
assert!(error.contains("would over-constrain the sketch (conflicts with"), "{error}");
assert_eq!(serde_json::to_value(sketch(&s)).unwrap(), before);
}
/// #754: an arc and one of its end points, in either order, dimension the arc's length (the
/// pair was refused). A line's end that meets the arc's end counts too; another point does not.
#[test]
fn an_arc_and_its_end_point_dimension_the_arc_length() {
let arc_length = |s: &Session, a: &str| {
let sk = sketch(s);
match sk.segs(sk.curve_index(a).unwrap())[0] {
solidcraft_geom::Seg2::Arc { radius, sweep, .. } => radius * sweep.abs(),
_ => f64::NAN,
}
};
for (entities, value) in [(json!(["a1", "a1.end"]), 20.0), (json!(["a1.start", "a1"]), 18.0), (json!(["a1", "l1.start"]), 25.0)] {
let mut s = new_sketch();
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10], "id": "a1"}));
run(&mut s, "sketch.line", json!({"points": [[0, 10], [-15, 10]], "ids": ["l1"]}));
let r = run(&mut s, "sketch.dimension", json!({"entities": entities, "value": value}));
assert_eq!(r["type"], "ArcLengthDimension", "{entities}: {r}");
assert!((arc_length(&s, "a1") - value).abs() < 1e-6, "{entities}: {}", arc_length(&s, "a1"));
}
let mut s = new_sketch();
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [10, 0], "end": [0, 10], "id": "a1"}));
run(&mut s, "sketch.point", json!({"point": [20, 20], "id": "p9"}));
let e = s.execute("sketch.dimension", &json!({"entities": ["a1", "p9"]})).map(|v| v["type"].clone());
assert!(!matches!(e, Ok(ref t) if t == "ArcLengthDimension"), "a point off the arc's ends is not an arc length: {e:?}");
}
+12
View File
@@ -26,6 +26,18 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned)"),
];
/// The sketch import command for a drawing file name, regardless of extension case.
pub fn drawing_import_command(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path).extension()?.to_str()?;
if ext.eq_ignore_ascii_case("dxf") {
Some("sketch.insert_dxf")
} else if ext.eq_ignore_ascii_case("svg") {
Some("sketch.insert_svg")
} else {
None
}
}
fn source_text(p: &Value, cmd: &str) -> Result<String> {
if let Some(t) = str_(p, "text") {
if t.len() > MAX_DRAWING_BYTES {
+2 -10
View File
@@ -95,12 +95,12 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "PATTERN")
.icon("sketch_circ_pattern")
.enabled(in_sketch)
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500 (number or unitless expression, retained through edits), angle?: total deg (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500 (number or unitless expression, retained through edits), angle?: total deg, number or expression (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SKETCH", "PATTERN")
.icon("sketch_rect_pattern")
.enabled(in_sketch)
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; counts: positive integer numbers or expressions, at most 500 instances total; spacings: length numbers or expressions; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
];
const TOL: f64 = 1e-7;
@@ -2520,14 +2520,6 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
}
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
let n = num(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
if !(1.0..=MAX_COPIES as f64).contains(&n) || n.fract() != 0.0 {
return Err(bad(cmd, format!("`{k}` must be 1…{MAX_COPIES}")));
}
Ok(n as usize)
}
fn point_or_ref(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result<Vec2> {
match p.get(k) {
Some(Value::String(r)) => sk.resolve_point(r).and_then(|i| sk.point(i)).ok_or_else(|| bad(cmd, format!("unknown point `{r}`"))),
+55 -137
View File
@@ -1,6 +1,6 @@
//! Associative sketch patterns. Editing retains ids at surviving instance indices.
use solidcraft_sketch::{PatternInstance, SketchPattern, SketchPatternKind};
use solidcraft_sketch::{SketchPattern, SketchPatternKind};
use super::*;
use crate::cmd::{alignment_param, reversed_param};
@@ -50,6 +50,30 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
}
}
}
let mut expressions = old.as_ref().map(|old| old.expressions.clone()).unwrap_or_default();
for key in ["count", "count2", "spacing", "spacing2", "angle"] {
if p.get(key).is_some() {
expressions.remove(key);
}
if let Some(Value::String(e)) = p.get(key) {
expressions.insert(key.into(), e.clone());
}
}
for (key, expression) in &expressions {
let kind = match key.as_str() {
"count" | "count2" => solidcraft_doc::expr::Kind::Unitless,
"angle" => solidcraft_doc::expr::Kind::Angle,
_ => solidcraft_doc::expr::Kind::Length,
};
let value = doc.eval(expression, kind).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
params[key] = json!(if key == "angle" { value.to_degrees() } else { value });
}
if !circular
&& params.get("spacing2").is_none()
&& let Some(e) = expressions.get("spacing").cloned()
{
expressions.insert("spacing2".into(), e);
}
let (mut cs, mut ps) = entities(sk, &params, cmd)?;
cs.sort_unstable();
cs.dedup();
@@ -76,23 +100,18 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
}
}
let count_expression = if circular {
match params.get("count") {
Some(Value::String(expression)) => {
let expression = expression.trim().to_string();
let value = doc.eval(&expression, solidcraft_doc::expr::Kind::Unitless)?;
params["count"] = json!(SketchPattern::circular_quantity(value)?);
Some(expression)
}
_ => None,
let expression =
expressions.remove("count").or_else(|| old.as_ref().and_then(|p| p.count_expression.clone()).filter(|_| p.get("count").is_none()));
if let Some(expression) = &expression {
let value = doc.eval(expression, solidcraft_doc::expr::Kind::Unitless)?;
params["count"] = json!(SketchPattern::circular_quantity(value).map_err(|e| bad(cmd, format!("`count`: {e}")))?);
}
expression
} else {
None
};
let n = count(&params, "count", cmd)?;
if count_expression.is_some() && n < 2 {
return Err(bad(cmd, "circular pattern Quantity must be 2…500"));
}
let (kind, center, slots) = if circular {
let (kind, center) = if circular {
let total = if params.get("angle").is_some() { num(&params, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 360.0 };
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
return Err(bad(cmd, "`angle` must be nonzero and at most 360 degrees"));
@@ -108,15 +127,9 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
None => return Err(bad(cmd, "missing `center`")),
};
let id = sk.points.get(center).ok_or_else(|| bad(cmd, "missing center point"))?.id.clone();
let full = (total.abs() - 360.0).abs() < 1e-9;
let step = total / if full { n as f64 } else { (n.max(2) - 1) as f64 };
let alignment = alignment_param(&params, "alignment", cmd)?;
let reversed = reversed_param(&params, "reversed", cmd)?;
(
SketchPatternKind::Circular { center: id, count: n, angle: total, alignment, reversed },
Some(center),
(1..n).map(|i| ([i, 0], (step * alignment.multiplier(i, reversed)).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
)
(SketchPatternKind::Circular { center: id, count: n, angle: total, alignment, reversed }, Some(center))
} else {
let spacing = num(&params, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be finite"))?;
let count2 = if params.get("count2").is_some() { count(&params, "count2", cmd)? } else { 1 };
@@ -144,19 +157,6 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
let alignment2 = alignment_param(&params, "alignment2", cmd)?;
let reversed = reversed_param(&params, "reversed", cmd)?;
let reversed2 = reversed_param(&params, "reversed2", cmd)?;
let first = line_direction(sk, direction.as_deref()).unwrap_or(dir);
let second = line_direction(sk, direction2.as_deref()).or(dir2).unwrap_or_else(|| first.perp());
let slots = (0..n)
.flat_map(|i| {
(0..count2).filter_map(move |j| {
(i != 0 || j != 0).then_some((
[i, j],
0.0,
first * (spacing * alignment.multiplier(i, reversed)) + second * (spacing2 * alignment2.multiplier(j, reversed2)),
))
})
})
.collect();
(
SketchPatternKind::Rectangular {
dir,
@@ -173,7 +173,6 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
reversed2,
},
None,
slots,
)
};
let id = match &old {
@@ -181,82 +180,20 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
None => sk.fresh("pat")?,
};
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
let mut existing = old.map(|old| old.instances).unwrap_or_default();
// Remove retired instances first: all following indices are resolved by stable id.
for instance in existing.iter().filter(|instance| !slots.iter().any(|(index, _, _)| *index == instance.index)) {
let curves: Vec<_> = instance.curves.iter().filter_map(|id| sk.curve_index(id)).collect();
sk.constraints.retain(|c| !instance.constraints.contains(&c.id));
sk.remove_curves(&curves);
let points: Vec<_> = instance.points.iter().filter_map(|(_, id)| sk.resolve_point(id)).collect();
sk.remove_points(&points);
}
let mut instances = Vec::new();
let center = center_id.as_ref().and_then(|id| sk.resolve_point(id));
if center_id.is_some() && center.is_none() {
return Err(bad(cmd, "the center belongs to an instance removed by this edit"));
}
if !circular {
// A direction can have belonged to an instance retired above. Resolve it again
// before creating relations so an edit never silently substitutes a vector.
direction_ref(sk, &params, "direction", cmd)?;
direction_ref(sk, &params, "direction2", cmd)?;
}
let mut curves = Vec::new();
let mut constraints = Vec::new();
for (index, angle, offset) in slots {
let (base_offset, directions) = linear_displacement(sk, &kind, index, offset);
let c = center.and_then(|i| sk.point(i)).unwrap_or(Vec2::ZERO);
let transform = |p| rotate(c, angle)(p) + offset;
let instance = if let Some(i) = existing.iter().position(|instance| instance.index == index) {
let instance = existing.remove(i);
for cid in &instance.constraints {
if let Some(constraint) = sk.constraints.iter_mut().find(|constraint| constraint.id == *cid)
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, directions: dirs, .. } = &mut constraint.kind
{
*slot = center;
*a = angle;
*off = base_offset;
*dirs = directions;
}
}
for (seed, copy) in &instance.points {
let pos = sk.resolve_point(seed).and_then(|i| sk.point(i)).ok_or_else(|| bad(cmd, "missing seed point"))?;
if let Some(point) = sk.resolve_point(copy).and_then(|i| sk.points.get_mut(i)) {
point.pos = transform(pos);
}
}
instance
} else {
let cs: Vec<_> = entity_ids.iter().filter_map(|id| sk.curve_index(id)).collect();
let ps: Vec<_> = entity_ids.iter().filter(|id| sk.curve_index(id).is_none()).filter_map(|id| sk.resolve_point(id)).collect();
let (made, map) = copy_through(sk, &cs, &ps, &transform, false, &|_| false, 1.0)?;
let mut cons = Vec::new();
for (p, q) in &map {
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset: base_offset, directions })?);
}
for (a, b) in cs.iter().zip(&made) {
if matches!(sk.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
cons.push(add_c(sk, ConstraintKind::PatternRadius { a: *a, b: *b })?);
}
}
PatternInstance {
index,
curves: ids_of(sk, &made),
points: map.iter().filter_map(|(p, q)| Some((sk.points.get(*p)?.id.clone(), sk.points.get(*q)?.id.clone()))).collect(),
constraints: cons,
}
};
curves.extend(instance.curves.iter().cloned());
constraints.extend(instance.constraints.iter().cloned());
instances.push(instance);
}
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
let pattern = SketchPattern { id: id.clone(), entities: entity_ids, kind, count_expression, instances };
if let Some(i) = old_index {
sk.patterns.insert(i.min(sk.patterns.len()), pattern);
} else {
sk.patterns.push(pattern);
}
let index = old_index.unwrap_or(sk.patterns.len());
let pattern = SketchPattern {
id: id.clone(),
entities: entity_ids,
kind: kind.clone(),
instances: old.map(|old| old.instances).unwrap_or_default(),
expressions,
count_expression,
};
sk.patterns.insert(index.min(sk.patterns.len()), pattern);
sk.update_pattern(index, kind)?;
let pattern = sk.patterns.get(index).ok_or_else(|| bad(cmd, "missing sketch pattern"))?;
let curves: Vec<_> = pattern.instances.iter().flat_map(|i| i.curves.iter().cloned()).collect();
let constraints: Vec<_> = pattern.instances.iter().flat_map(|i| i.constraints.iter().cloned()).collect();
Ok(json!({"pattern":id,"center":center_id,"curves":curves,"constraints":constraints}))
})?;
let mut out = out;
@@ -266,6 +203,14 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
Ok(out)
}
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
let n = num(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or expression")))?;
if !(1.0..=MAX_COPIES as f64).contains(&n) || n.fract() != 0.0 {
return Err(bad(cmd, format!("`{k}` must be a positive integer from 1 to {MAX_COPIES}")));
}
Ok(n as usize)
}
fn direction_ref(sk: &Sketch, p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
@@ -278,30 +223,3 @@ fn line_direction(sk: &Sketch, id: Option<&str>) -> Option<Vec2> {
let Shape::Line { a, b } = sk.shape(sk.curve_index(id?)?)? else { return None };
(b - a).normalized()
}
/// Keep sampled vector directions compatible with older designs, while picked lines are
/// solver references. The default second direction follows the first line's perpendicular.
fn linear_displacement(sk: &Sketch, kind: &SketchPatternKind, [i, j]: [usize; 2], fallback: Vec2) -> (Vec2, [Option<(usize, Vec2)>; 2]) {
let SketchPatternKind::Rectangular { dir, dir2, direction, direction2, spacing, spacing2, alignment, alignment2, reversed, reversed2, .. } = kind
else {
return (fallback, [None; 2]);
};
let mut offset = Vec2::ZERO;
let mut directions = [None; 2];
let d1 = *spacing * alignment.multiplier(i, *reversed);
let d2 = *spacing2 * alignment2.multiplier(j, *reversed2);
if let Some(l) = direction.as_ref().and_then(|id| sk.curve_index(id)) {
directions[0] = Some((l, Vec2::new(d1, if dir2.is_none() && direction2.is_none() { d2 } else { 0.0 })));
} else {
offset += *dir * d1;
if dir2.is_none() && direction2.is_none() {
offset += dir.perp() * d2;
}
}
if let Some(l) = direction2.as_ref().and_then(|id| sk.curve_index(id)) {
directions[1] = Some((l, Vec2::new(d2, 0.0)));
} else if let Some(dir2) = dir2 {
offset += *dir2 * d2;
}
(offset, directions)
}
@@ -678,3 +678,80 @@ fn a_pattern_too_large_to_solve_says_why() {
.to_string();
assert!(e.contains("unknowns") && e.contains(&solidcraft_sketch::MAX_COMPONENT_VARS.to_string()) && !e.contains("conflicts with"), "{e}");
}
#[test]
fn rectangular_pattern_expressions_follow_parameters_and_survive_reopening() {
let mut s = new_sketch();
for (name, expression, unit) in [("pitch", "30", "mm"), ("copies", "3", "")] {
run(&mut s, "parameters.add", json!({"name":name,"expression":expression,"unit":unit}));
}
run(&mut s, "sketch.circle.center", json!({"id":"seed","center":[5,5],"radius":1}));
let out = run(
&mut s,
"sketch.pattern.rectangular",
json!({"entities":["seed"],"count":"copies","spacing":"pitch","count2":"2","spacing2":"pitch / 2"}),
);
let first = ids(&out["curves"])[0].clone();
assert!(!s.doc.param_users("pitch").is_empty());
run(&mut s, "sketch.finish", json!({}));
let mut s = Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), ..Default::default() };
s.refresh();
run(&mut s, "parameters.change", json!({"name":"pitch","expression":"40"}));
run(&mut s, "parameters.change", json!({"name":"copies","expression":"4"}));
let state = s.world_state();
let sk = &state.sketch(1).unwrap().sketch;
assert_eq!(sk.patterns[0].instances.len(), 7);
assert_eq!(sk.patterns[0].instances[0].curves[0], first);
assert!(point(sk, &format!("{first}.center")).dist(Vec2::new(5., 25.)) < 1e-6);
let last = &sk.patterns[0].instances.last().unwrap().curves[0];
assert!(point(sk, &format!("{last}.center")).dist(Vec2::new(125., 25.)) < 1e-6);
run(&mut s, "parameters.change", json!({"name":"copies","expression":"2"}));
assert_eq!(s.world_state().sketch(1).unwrap().sketch.patterns[0].instances.len(), 3);
run(&mut s, "parameters.change", json!({"name":"pitch","new_name":"step"}));
assert_eq!(serde_json::to_value(&s.doc.sketch(1).unwrap().patterns[0]).unwrap()["expressions"]["spacing"], "step");
assert!(s.execute("parameters.change", &json!({"delete":"step"})).is_err());
}
#[test]
fn circular_pattern_expressions_validate_inputs_and_keep_numeric_compatibility() {
let mut s = new_sketch();
for (name, expression, unit) in [("turn", "90", "deg"), ("copies", "3", "")] {
run(&mut s, "parameters.add", json!({"name":name,"expression":expression,"unit":unit}));
}
run(&mut s, "sketch.circle.center", json!({"id":"seed","center":[20,0],"radius":1}));
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["seed"],"center":"origin","count":"copies","angle":"turn"}));
run(&mut s, "parameters.change", json!({"name":"turn","expression":"180"}));
run(&mut s, "parameters.change", json!({"name":"copies","expression":"4"}));
let sk = sketch(&s);
let last = &sk.patterns[0].instances.last().unwrap().curves[0];
assert!(point(&sk, &format!("{last}.center")).dist(Vec2::new(-20., 0.)) < 1e-6);
for expression in ["2.5", "501", "0", "-1"] {
let error = s.execute("parameters.change", &json!({"name":"copies","expression":expression})).unwrap_err().to_string();
assert!(error.contains("whole number"), "{error}");
}
for (key, value, message) in [("count", json!("2.5"), "count"), ("count", json!("501"), "count"), ("angle", json!("missing + 1"), "missing")] {
let mut p = json!({"pattern":out["pattern"]});
p[key] = value;
let error = s.execute("sketch.pattern.circular", &p).unwrap_err().to_string();
assert!(error.contains(message), "{error}");
}
let error = s.execute("sketch.pattern.rectangular", &json!({"entities":["seed"],"count":2,"spacing":"unknown_pitch"})).unwrap_err().to_string();
assert!(error.contains("spacing") && error.contains("unknown_pitch"), "{error}");
run(&mut s, "sketch.pattern.circular", json!({"pattern":out["pattern"],"count":3,"angle":90}));
assert!(serde_json::to_value(&sketch(&s).patterns[0]).unwrap().get("expressions").is_none());
let legacy = s.doc.to_json();
// Numeric patterns omit the optional expression field, just as older files do.
assert!(!s.doc.to_json().contains("\"expressions\""));
let reopened = solidcraft_doc::format::read(&legacy).unwrap();
assert_eq!(reopened.sketch(1).unwrap().patterns[0].instances.len(), 2);
// A design with plain numbers saves back unchanged and evaluates to the same copies.
assert_eq!(reopened.to_json(), legacy);
let before = sketch(&s);
let mut s = Session { doc: reopened.into(), ..Default::default() };
s.refresh();
let state = s.world_state();
let after = &state.sketch(1).unwrap().sketch;
for (a, b) in before.points.iter().zip(&after.points) {
assert!(a.id == b.id && a.pos.dist(b.pos) < 1e-9, "{} moved", a.id);
}
}
+25 -3
View File
@@ -26,6 +26,9 @@ enum K {
Make,
/// A direction: turned with the command's frame.
Dir,
/// A point the command's bodies move about: into the frame of those bodies (the command's
/// frame, as its directions are).
Moved,
/// An arbitrary axis object in the authoring frame: {origin, dir}.
Axis,
/// A plane object (`{face}`, `{origin, normal}`, `{origin, x_dir, y_dir}`) or a plane name.
@@ -61,7 +64,10 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"solid.draft" => &[("faces", Picks), ("neutral", Plane), ("pull", Dir), ("body", Body)],
"solid.press_pull" => &[("face", Pick), ("edges", Picks)],
"solid.replace_face" => &[("faces", Picks), ("target_face", Pick), ("target", Plane), ("body", Body)],
"solid.align" => &[("from", Pick), ("from_face", Pick), ("to", Pick), ("to_face", Pick), ("bodies", Body)],
// Align finds its picks where they are shown and takes both ends into the moved bodies'
// frame itself: a pick mapped into the frame of the body under it would put the two ends
// in different frames when they lie on separately placed components.
"solid.align" => &[("bodies", Body)],
// A mirror keeps the placement its bodies were picked at (`cmd::features::placed_bodies`);
// its plane is in the active component, where the copies go.
"solid.mirror" => &[("plane", HerePlane)],
@@ -84,7 +90,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"inspect.curvature_comb" | "inspect.minimum_radius" | "inspect.isocurve" => &[("edges", Picks), ("faces", Picks)],
"parts.insert" | "parts.fastener" => &[("at", Pick), ("point", Make), ("direction", Dir)],
"appearance.assign" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
"solid.move" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Make)],
"solid.move" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Moved)],
"solid.combine" => &[("target", Body), ("tools", Body)],
"solid.remove" | "solid.bounding_solid" | "material.assign" => &[("bodies", Body)],
_ => &[],
@@ -444,7 +450,19 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
command_frames.active = frame;
command_frames.sketch = frame;
}
let mut m = Mapper { s, f: command_frames, first: None, changed: false, here: false };
// A move has no picks: its vector, axis and pivot are in the world and turn with the frame
// of the bodies it moves (an imported part stood upright moved up goes up, not along its
// own frame's z). A turn with no pivot is about the world origin.
let first = (id == "solid.move")
.then(|| obj.get("bodies").and_then(Value::as_array)?.first()?.as_str())
.flatten()
.and_then(|b| command_frames.bodies(s).iter().find(|(world, own, _)| world == b || own == b).map(|(.., m)| *m));
let mut obj = obj.clone();
if first.is_some() && obj.contains_key("axis") && !obj.contains_key("origin") {
obj.insert("origin".into(), serde_json::json!([0.0, 0.0, 0.0]));
}
let obj = &obj;
let mut m = Mapper { s, f: command_frames, first, changed: false, here: false };
let body = obj.get("body").or_else(|| obj.get("face_body")).and_then(Value::as_str).map(str::to_string);
let mut out: Map<String, Value> = obj.clone();
// Picks first: their frame turns the directions.
@@ -460,6 +478,10 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
Picks => m.picks(v, body.as_deref()),
Make => m.make(v),
Dir => m.dir(v),
Moved => {
let frame = m.first.unwrap_or(m.f.active);
vec3(v).and_then(|p| m.local_point(&frame, p))
}
Axis => {
if let Some(axis) = v.as_object() {
let mut axis = axis.clone();
+26 -7
View File
@@ -150,6 +150,10 @@ pub enum Sel {
Plane {
name: String,
},
/// A construction point, by feature name.
ConstructionPoint {
name: String,
},
/// An origin axis (X, Y, Z) or a construction axis (by feature name).
Axis {
name: String,
@@ -327,6 +331,8 @@ pub struct Session {
pub section: Option<(Vec3, Vec3)>,
/// Whether the section's cut faces are hatched (`inspect.section`'s `hatch`; on by default).
pub section_hatch: bool,
/// Component identification colours are enabled when present; view state only.
pub colored_components: Option<()>,
/// Surface analysis shading the model (zebra, draft, curvature map).
pub analysis: Option<SurfaceAnalysis>,
/// The seams `inspect.continuity` last measured, for the viewport to shade. View state: not
@@ -491,6 +497,7 @@ impl Session {
sketch_clipboard: None,
section: None,
section_hatch: true,
colored_components: None,
analysis: None,
continuity: Vec::new(),
checkpoints: Vec::new(),
@@ -888,7 +895,9 @@ impl Session {
.features
.iter()
.filter_map(|feature| match &feature.kind {
solidcraft_doc::FeatureKind::Sketch { sketch, .. } if sketch.patterns.iter().any(|p| p.count_expression.is_some()) => {
solidcraft_doc::FeatureKind::Sketch { sketch, .. }
if sketch.patterns.iter().any(|p| p.count_expression.is_some() || !p.expressions.is_empty()) =>
{
Some((feature.id, sketch.clone()))
}
_ => None,
@@ -897,7 +906,7 @@ impl Session {
if !updated.is_empty() {
let (vals, _) = self.doc.param_values();
for (_, sketch) in &mut updated {
self.doc.apply_pattern_quantities(&vals, sketch)?;
self.doc.apply_pattern_values(&vals, sketch)?;
}
}
for (id, sketch) in updated {
@@ -1152,6 +1161,7 @@ impl Session {
sketch_clipboard: self.sketch_clipboard.clone(),
section: None,
section_hatch: true,
colored_components: None,
analysis: None,
continuity: Vec::new(),
checkpoints: Vec::new(),
@@ -1401,11 +1411,18 @@ pub(crate) type SketchValues = std::collections::BTreeMap<u64, std::collections:
fn own_values(sk: &solidcraft_sketch::Sketch) -> std::collections::BTreeMap<String, f64> {
let mut values: std::collections::BTreeMap<_, _> =
sk.constraints.iter().filter(|c| c.param.is_some() && !c.driven).filter_map(|c| Some((c.id.clone(), c.kind.value()?))).collect();
for pattern in &sk.patterns {
if pattern.count_expression.is_some()
&& let solidcraft_sketch::SketchPatternKind::Circular { count, .. } = pattern.kind
{
values.insert(format!("pattern:{}:count", pattern.id), count as f64);
for p in &sk.patterns {
use solidcraft_sketch::SketchPatternKind;
let inputs = match p.kind {
SketchPatternKind::Circular { count, angle, .. } => vec![("count", count as f64), ("angle", angle)],
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, .. } => {
vec![("count", count as f64), ("spacing", spacing), ("count2", count2 as f64), ("spacing2", spacing2)]
}
};
for (key, value) in inputs {
if p.expressions.contains_key(key) || (key == "count" && p.count_expression.is_some()) {
values.insert(format!("{}.{}", p.id, key), value);
}
}
}
values
@@ -1458,6 +1475,7 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
browser_order,
named_views,
visibility,
next_component_color_index,
} = a;
*render == b.render
&& *format == b.format
@@ -1491,4 +1509,5 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
&& *browser_order == b.browser_order
&& *named_views == b.named_views
&& *visibility == b.visibility
&& *next_component_color_index == b.next_component_color_index
}
+15 -1
View File
@@ -115,7 +115,7 @@ impl<P: DeserializeOwned + JsonSchema + 'static> Contract for TypedContract<P> {
}
/// The most keys of one call that are looked at, and the longest key given a suggestion.
const MAX_KEYS: usize = 64;
pub(crate) const MAX_KEYS: usize = 64;
const MAX_KEY_LEN: usize = 64;
const MAX_MESSAGE_LEN: usize = 300;
const MAX_WHOLE_LEN: usize = 4096;
@@ -260,6 +260,20 @@ pub(crate) fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) ->
Issue { path, message: truncate(&message, MAX_MESSAGE_LEN), suggestion: None }
}
/// Run `handler` as the command `id`, checking the call against the contract `id` publishes
/// (its [`CommandSpec::contract`](crate::cmd::CommandSpec::contract)), so the contract enforced is
/// always the one described. With the level off and no audit recorder, this is exactly
/// `handler(s, p)`, without looking anything up; a command without a contract just runs.
pub fn run_checked_as(s: &mut Session, p: &Value, id: &str, handler: Run) -> Result<Value> {
if s.param_check == ParamCheck::Off && !record::enabled() {
return handler(s, p);
}
match crate::cmd::find_command(id).and_then(|c| c.contract) {
Some(contract) => run_checked(s, p, id, contract, handler),
None => handler(s, p),
}
}
/// Run `handler` as the command `id`, checking the call against `contract` as the session's
/// [`ParamCheck`] level asks (see the module docs). With the level off and no audit recorder,
/// this is exactly `handler(s, p)`.
+106
View File
@@ -0,0 +1,106 @@
//! New commands come with schemas: every command has a contract (its input schema) and an
//! output schema, apart from `planned!` placeholders and the commands listed in
//! `unconverted.txt`. That list holds the commands that predate the rule; it only shrinks.
use std::collections::BTreeSet;
use serde_json::json;
use crate::Session;
use crate::cmd::command_specs;
use crate::schema::ParamCheck;
use crate::schema::check::MAX_KEYS;
/// Whether a command without schemas fails the test. Off for now: the test warns instead. The
/// owner will flip this to `true` when the PR backlog is down. `SOLIDCRAFT_SCHEMA_RULE_STRICT=1`
/// turns it on for one run.
const SCHEMA_RULE_STRICT: bool = false;
const UNCONVERTED: &str = include_str!("unconverted.txt");
/// The ids in `unconverted.txt`, in file order (`#` lines are comments).
fn unconverted() -> Vec<&'static str> {
UNCONVERTED.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect()
}
#[test]
fn every_command_has_input_and_output_schemas() {
let listed: BTreeSet<&str> = unconverted().into_iter().collect();
let mut problems = Vec::new();
let mut missing = Vec::new();
for c in command_specs() {
let has = (c.contract.is_some(), c.output.is_some());
let listed = listed.contains(c.id);
if c.planned.is_some() {
if listed {
problems.push(format!("`{}` is a planned! placeholder, which needs no line in unconverted.txt", c.id));
}
continue;
}
match (has, listed) {
((true, true), true) => problems.push(format!("`{}` has its schemas now: remove its line from unconverted.txt", c.id)),
((true, true), false) | (_, true) => {}
((input, output), false) => missing.push(format!(
"`{}` has no {} schema. Every new command needs a contract (`.contract(…)`, run through `checked!`) \
and an output type (`.output(…)`): see docs/book/src/dev/schemas.md",
c.id,
match (input, output) {
(true, _) => "output",
(false, true) => "input",
(false, false) => "input or output",
}
)),
}
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
if !missing.is_empty() {
let strict = SCHEMA_RULE_STRICT || std::env::var_os("SOLIDCRAFT_SCHEMA_RULE_STRICT").is_some_and(|v| v != "0");
let report = format!(
"{} command(s) lack an input or output schema. This is a warning for now; it will become required:\n{}",
missing.len(),
missing.join("\n")
);
assert!(!strict, "{report}");
eprintln!("warning: {report}");
// A GitHub Actions annotation, so the warning shows on the CI run page too.
println!("::warning title=Commands without schemas::{}", missing.len());
}
}
/// A contract that is published but not checked would describe calls nobody refuses: every
/// command with a contract runs through `checked!` with its own id, which checks the contract
/// that id publishes. Calls `run` itself (not `Session::execute`, which first asks `enabled`)
/// in strict with an unknown key, which must be refused under the command's own id before the
/// handler sees it.
#[test]
fn every_contract_is_checked_in_front_of_its_handler() {
const PROBE: &str = "not_a_key_of_any_command";
for c in command_specs() {
let Some(contract) = c.contract else { continue };
// The check reads at most `MAX_KEYS` keys of a call, so a contract can't take more.
let keys = contract.allowed_keys().len();
assert!(keys <= MAX_KEYS, "`{}` takes {keys} keys; a call may have at most {MAX_KEYS}, so no call could use them all", c.id);
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
let run = c.run;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&mut s, &json!({PROBE: 1}))));
let fix = format!("give its CommandSpec `crate::checked!(\"{}\", <its handler>)`", c.id);
let error = match result {
Ok(Err(e)) => e.to_string(),
Ok(Ok(v)) => panic!("`{}` ran with an unknown key in strict ({v}): {fix}", c.id),
Err(_) => panic!("`{}` panicked on an unknown key in strict: {fix}", c.id),
};
let expected = format!("invalid parameters for `{}`: unknown key `{PROBE}`", c.id);
assert!(error.starts_with(&expected), "`{}` should refuse with \"{expected}\", not \"{error}\": {fix}", c.id);
}
}
#[test]
fn the_unconverted_list_is_sorted_and_names_real_commands() {
let ids = unconverted();
for pair in ids.windows(2) {
assert!(pair[0] < pair[1], "unconverted.txt must be sorted, each id once: `{}` comes before `{}`", pair[0], pair[1]);
}
let registered: BTreeSet<&str> = command_specs().iter().map(|c| c.id).collect();
let stale: Vec<&str> = ids.into_iter().filter(|id| !registered.contains(id)).collect();
assert!(stale.is_empty(), "unconverted.txt names commands that don't exist (remove their lines): {stale:?}");
}
+15 -6
View File
@@ -36,12 +36,20 @@
//! `strict_keeps_todays_errors`. Then run the whole test suite with the audit recorder (see
//! `record.rs`): a call that succeeds today but breaks the contract is either a form the
//! contract is missing or a caller's mistake.
//!
//! New commands come with both schemas: `coverage_tests.rs` warns about a command without a
//! contract or an output type (it will fail once `SCHEMA_RULE_STRICT` flips), unless it is a `planned!` placeholder or listed in
//! `unconverted.txt`, which holds the commands that predate the rule. That list only shrinks:
//! converting a command removes its line, and nothing is added to it. How to write a contract,
//! with the shared types and a checklist: docs/book/src/dev/schemas.md.
mod check;
#[cfg_attr(target_arch = "wasm32", path = "record_wasm.rs")]
mod record;
mod types;
#[cfg(test)]
mod coverage_tests;
#[cfg(test)]
mod tests;
@@ -49,16 +57,17 @@ mod tests;
pub mod features;
pub mod inspect;
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked};
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked, run_checked_as};
pub use types::*;
/// A command's `run` that checks the call against `$contract` before running `$handler`
/// (see [`run_checked`]). Expands to a non-capturing closure, which coerces to the registry's
/// `Run` function pointer.
/// A command's `run` that checks the call against the contract command `$id` publishes
/// (`.contract(…)` on its `CommandSpec`) before running `$handler` (see [`run_checked_as`]), so
/// the contract enforced is always the one described. Expands to a non-capturing closure, which
/// coerces to the registry's `Run` function pointer.
#[macro_export]
#[doc(hidden)]
macro_rules! checked {
($id:literal, $contract:path, $handler:path) => {
|s: &mut $crate::Session, p: &serde_json::Value| $crate::schema::run_checked(s, p, $id, &$contract, $handler)
($id:literal, $handler:path) => {
|s: &mut $crate::Session, p: &serde_json::Value| $crate::schema::run_checked_as(s, p, $id, $handler)
};
}
+461
View File
@@ -0,0 +1,461 @@
# Commands that predate the rule that every command has input and output schemas
# (coverage_tests.rs). This list only shrinks: when you give a command its contract and output
# type, remove its line. Never add one: a new command comes with both schemas. Sorted, one id
# per line.
appearance.assign
appearance.library
appearance.list
assembly.bom
body.rename
browser.collapse_all
browser.expand_all
browser.group
browser.move
browser.order
browser.rename_group
browser.ungroup
browser.visibility
canvas.calibrate
canvas.decal
canvas.delete
canvas.edit
canvas.insert
component.activate
component.break_link
component.capture_position
component.copies
component.create
component.delete
component.extract
component.from_bodies
component.insert_design
component.links
component.list
component.metadata
component.metadata.set
component.move_bodies
component.move_sketches
component.paste_new
component.properties
component.relink
component.rename
component.replace_with_link
component.revert_position
component.unlock
component.update_links
config.activate
config.column
config.configure
config.preview
config.row
config.rule
config.rules
config.table
construct.axis.edge
construct.axis.normal_to_face
construct.axis.through_cylinder
construct.axis.two_planes
construct.axis.two_points
construct.plane.along_path
construct.plane.at_angle
construct.plane.midplane
construct.plane.offset
construct.plane.perpendicular
construct.plane.tangent
construct.plane.three_points
construct.plane.two_edges
construct.point.along_path
construct.point.center
construct.point.edge_plane
construct.point.three_planes
construct.point.two_edges
construct.point.vertex
contact.check
contact.create
contact.disable_all
contact.edit
contact.enable_all
contact.enable_sets
contact.list
diagnostics.crash_report
diagnostics.crash_reports
diagnostics.crash_reports.clear
diagnostics.crash_test
doc.open
doc.recover
doc.recovery_discard
doc.recovery_list
document.inspect
document.metadata
document.metadata.remove
document.metadata.set
document.units
edit.checkpoint
edit.redo
edit.restore_checkpoint
edit.undo
engine.commands
explode.create
explode.delete
explode.list
explode.show
expr.evaluate
face.delete
file.export
file.export_view_dxf
file.insert_mesh
file.insert_step
file.new
file.save
file.save_as
file.save_mesh
file.slicers
frame.member
frame.profile.insert
frame.profile.list
frame.profile.properties
inspect.accessibility
inspect.center_of_mass
inspect.clearance
inspect.compare
inspect.continuity
inspect.curvature_comb
inspect.curvature_map
inspect.draft
inspect.environment_map
inspect.interference
inspect.isocurve
inspect.measure
inspect.minimum_radius
inspect.parameter_sweep
inspect.section
inspect.silhouette
inspect.zebra
joint.as_built
joint.cam
joint.create
joint.delete
joint.drive
joint.edit
joint.gear
joint.inspect
joint.limits
joint.list
joint.motion_link
joint.origin
joint.rack_pinion
joint.reach
joint.rigid_group
joint.screw
joint.solve
joint.sweep
library.apply
library.export
library.inspect
material.assign
material.create
material.delete
material.edit
material.inspect
material.list
material.properties.directional
material.properties.validate
mesh.convert
model.edges
model.faces
model.threads
motion.delete
motion.export
motion.export_animation
motion.interference
motion.list
motion.play
motion.plot
motion.study
motion.trace
motion.trace.edit
motion.trace.keep
occurrence.copy
occurrence.drag
occurrence.ground
occurrence.move
occurrence.properties
parameters.add
parameters.change
parameters.comment
parameters.complete
parameters.delete
parameters.export
parameters.favorite
parameters.graph
parameters.import
parameters.list
parameters.move
parameters.rename
parameters.users
parts.fastener
parts.insert
parts.library
parts.resize
plastic.assign_rule
plastic.boss
plastic.lip
plastic.manage_rules
plastic.rest
plastic.rules
plastic.snap_fit
render.aging.describe
render.backends
render.camera.fit
render.camera.get
render.camera.set
render.cancel
render.effects.describe
render.export
render.hdr.inspect
render.library.appearance.apply
render.library.describe
render.library.environment.apply
render.library.list
render.library.physical.prepare
render.library.preview
render.library.studio.apply
render.library.studio.prepare
render.library.surface.bind
render.library.surface.prepare
render.library.surface.unbind
render.library.targets
render.optics.bind
render.optics.unbind
render.preflight
render.preset.apply
render.preset.resolve
render.quality.prepare
render.result.pin
render.result.regrade
render.scene
render.settings.apply
render.settings.capture_hdr
render.settings.get
render.settings.set
render.shot.duplicate
render.shot.remove
render.shot.rename
render.stage.backdrop
render.stage.clear
render.stage.ground
render.start
render.status
select.clear
select.set
selection.delete
sheet.convert
sheet.export_bend_table
sheet.export_dxf
sheet.flange
sheet.flat_pattern
sheet.fold
sheet.hem
sheet.manage_rules
sheet.path_flange
sheet.perforate
sheet.refold
sheet.rules
sheet.unfold
sketch.arc.center_point
sketch.arc.tangent
sketch.arc.three_point
sketch.auto_constrain
sketch.auto_constrain_from_datum
sketch.auto_project
sketch.blend_curve
sketch.break
sketch.break_link
sketch.cancel
sketch.centerline
sketch.chain
sketch.chamfer.distance_angle
sketch.chamfer.equal_distance
sketch.chamfer.two_distance
sketch.circle.center
sketch.circle.three_point
sketch.circle.three_tangent
sketch.circle.two_point
sketch.circle.two_tangent
sketch.conic
sketch.constrainer
sketch.constraint.coincident
sketch.constraint.collinear
sketch.constraint.concentric
sketch.constraint.curvature
sketch.constraint.equal
sketch.constraint.fix
sketch.constraint.horizontal_vertical
sketch.constraint.midpoint
sketch.constraint.parallel
sketch.constraint.perpendicular
sketch.constraint.polygon
sketch.constraint.symmetry
sketch.constraint.tangent
sketch.construction
sketch.copy
sketch.create
sketch.cut
sketch.delete
sketch.dimension
sketch.dimension_text
sketch.drag
sketch.drag_end
sketch.draw.dimensioned
sketch.edit
sketch.edit_text
sketch.ellipse
sketch.export_dxf
sketch.extend
sketch.fillet
sketch.finish
sketch.finish_auto_constrain
sketch.fit_curves_to_section
sketch.glyphs
sketch.include_3d
sketch.infinite
sketch.insert_dxf
sketch.insert_svg
sketch.inspect
sketch.intersect
sketch.intersection_curve
sketch.isoparametric_curve
sketch.line
sketch.line.midpoint
sketch.line3d
sketch.loose_ends
sketch.loose_ends.tie
sketch.mirror
sketch.move
sketch.move3d
sketch.move_point
sketch.offset
sketch.offset.break
sketch.options
sketch.paste
sketch.pattern.circular
sketch.pattern.rectangular
sketch.point
sketch.point3d
sketch.polygon.circumscribed
sketch.polygon.edge
sketch.polygon.inscribed
sketch.project
sketch.project_to_surface
sketch.rectangle.center
sketch.rectangle.rounded
sketch.rectangle.three_point
sketch.rectangle.two_point
sketch.redefine
sketch.scale
sketch.slot.arc_center
sketch.slot.arc_three_point
sketch.slot.center_point
sketch.slot.center_to_center
sketch.slot.overall
sketch.snap
sketch.solve
sketch.spline.control_point
sketch.spline.control_point_5
sketch.spline.fit_point
sketch.spline3d
sketch.spun_profile
sketch.text
sketch.toggle_driven
sketch.trim
sketch.trim.path
solid.align
solid.boundary_fill
solid.bounding_solid
solid.chamfer
solid.coil
solid.combine
solid.cylinder
solid.draft
solid.emboss
solid.fillet
solid.hole
solid.loft
solid.mirror
solid.move
solid.offset_face
solid.pattern.circular
solid.pattern.path
solid.pattern.rectangular
solid.pipe
solid.press_pull
solid.remove
solid.replace_face
solid.revolve
solid.rib
solid.scale
solid.shell
solid.sphere
solid.split_body
solid.split_face
solid.sweep
solid.thread
solid.torus
solid.web
surface.blend
surface.boundary
surface.extend
surface.extrude
surface.fair
surface.loft
surface.match
surface.offset
surface.patch
surface.reverse_normal
surface.revolve
surface.ruled
surface.stitch
surface.sweep
surface.thicken
surface.trim
surface.unstitch
surface.untrim
timeline.add
timeline.compute_all
timeline.copy
timeline.delete
timeline.dependents
timeline.edit
timeline.export_script
timeline.group
timeline.inspect
timeline.move_many
timeline.move_to_group
timeline.paste
timeline.redefine
timeline.rename
timeline.rename_group
timeline.reorder
timeline.reorder_many
timeline.roll_to
timeline.rollback
timeline.suppress
timeline.ungroup
view.ambient_occlusion
view.apply
view.back
view.bottom
view.delete
view.front
view.graphics_preset
view.history_layout
view.isometric
view.layout
view.left
view.list
view.navigate
view.normal_to
view.rename
view.right
view.save
view.top
+161 -15
View File
@@ -209,6 +209,116 @@ pub fn grid_step(half_height: f64) -> (f64, f64) {
(minor, minor * 5.0)
}
/// How the grid fades out with the distance from the middle of the view (#4214).
///
/// The grid used to stop at a fixed distance, so its edge showed as a cut across the picture. It
/// now fades: at full strength near the middle of the view, and gone before the grid's own edge
/// and before the picture's far plane cuts the ground, so that neither of them shows.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GridFade {
/// The distance from the middle of the view where the grid starts to dim.
pub start: f64,
/// The distance from the middle of the view where the grid is gone.
pub end: f64,
}
impl GridFade {
/// The fade for a view of the given half height, seen at the given slant (the cosine of the
/// angle between the view and the grid's plane), over a grid of the given half size, that
/// reaches no further than `far_reach` before the picture's far plane cuts the ground.
pub fn new(half_height: f64, slant: f64, ext: f64, far_reach: f64) -> Self {
// The distance from the middle of the view to its edge, measured on the grid's plane: the
// ground stretches as it is seen at a slant, as the grid's own size does (#3087).
let reach = half_height / slant.abs().max(0.25);
// The grid is gone before its own edge, and before the far plane cuts the ground, so that
// neither of them shows as a cut.
let end = (2.2 * reach).min(ext).min(far_reach);
GridFade { start: 0.6 * reach.min(end), end }
}
/// How much of the grid is left at a distance from the middle of the view.
pub fn alpha(&self, d: f64) -> f32 {
if !d.is_finite() {
return 0.0;
}
if d <= self.start {
return 1.0;
}
if d >= self.end || self.end <= self.start {
return 0.0;
}
((self.end - d) / (self.end - self.start)) as f32
}
/// The pieces the grid's line at the given offset from the middle is drawn in: each with the
/// alpha the fade gives it at the distance where it lies. The line runs along the grid's other
/// axis; it is drawn whole where the grid is at full strength, and in pieces that fade
/// towards its ends.
pub fn pieces(&self, offset: f64) -> Vec<(f64, f64, f32)> {
let mut out = Vec::new();
let off = offset.abs();
if !(self.end > 0.0) || off >= self.end {
return out;
}
// Where the grid is still at full strength.
let solid = if off < self.start { (self.start * self.start - off * off).max(0.0).sqrt() } else { 0.0 };
if solid > 0.0 {
out.push((-solid, solid, 1.0));
}
// The fading ends, in pieces whose distance from the middle grows evenly, so that the
// grid dims evenly too.
let from = off.max(self.start);
for k in 0..GRID_FADE_STEPS {
let (d0, d1) =
(from + (self.end - from) * k as f64 / GRID_FADE_STEPS as f64, from + (self.end - from) * (k + 1) as f64 / GRID_FADE_STEPS as f64);
let (s0, s1) = ((d0 * d0 - off * off).max(0.0).sqrt(), (d1 * d1 - off * off).max(0.0).sqrt());
let alpha = self.alpha((d0 + d1) * 0.5);
if alpha > 0.0 {
out.push((s0, s1, alpha));
out.push((-s1, -s0, alpha));
}
}
out
}
}
/// How many pieces a grid line's fading end is drawn in: enough that the grid dims evenly, few
/// enough that the grid stays cheap to draw.
const GRID_FADE_STEPS: u32 = 12;
/// How far the grid reaches before the picture's far plane cuts the ground: the distance on the
/// grid's plane from the middle of the view to where the far plane ends it (#4214). Looking
/// straight down, the ground's depth does not grow and the far plane never cuts it.
///
/// `c` is the middle of the view and `n` the grid's normal, both in the world.
pub fn grid_far_reach(cam: &Camera, far: f64, c: Vec3, n: Vec3) -> f64 {
let eye = cam.eye();
// The direction the camera looks, and where it looks on the grid's plane: the ground's
// forward direction, which the depth grows along.
let view = -cam.back();
let forward = view - n * view.dot(n);
let len = forward.len();
if len < 1e-9 {
return f64::INFINITY;
}
let depth = (c - eye).dot(view);
if far <= depth {
return 0.0;
}
(far - depth) / len
}
/// The grid's half size for a view of the given half height, seen at the given slant (the cosine
/// of the angle between the view and the grid's plane): the grid reaches as far on its plane as
/// the view is wide, which stretches as the plane is seen at a slant (#3087).
///
/// The picture and the window share it, so that they draw the same grid (#4214).
pub fn grid_ext(half_height: f64, major: f64, slant: f64) -> f64 {
let major = major.max(1e-3);
let ext = (half_height * 3.0 / major).ceil() * major;
(ext / slant.abs().max(0.25) / major).ceil() * major
}
/// Sketch curves as world polylines with their colour.
pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec<Vec3>, Rgb, bool)> {
sketch_lines_with_sampler(sk, plane, active, determined, world_curve_parts)
@@ -348,6 +458,12 @@ pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
sk.points.get(i).is_some_and(|p| SketchPointVisibility::new(sk).takes(p))
}
/// Stable identification colour shared by every body of a component.
pub fn component_color(s: &Session, component: u64) -> Option<[u8; 3]> {
s.colored_components?;
Some(s.doc.component_identification_color(component))
}
/// The scene for a headless render, cut by the session's section.
pub fn scene(s: &Session, cam: &Camera) -> Scene {
scene_with_section(s, cam, s.section)
@@ -474,34 +590,52 @@ fn build_scene(
// Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the viewport.
if guides.grid {
let (minor, major) = grid_step(cam.half_height());
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
// Headless pictures keep the grid on the ground, the viewport's default; an open sketch
// has its own plane's grid (world coordinates, no camera frame).
let (c, u, v, w): (Vec3, Vec3, Vec3, Box<dyn Fn(Vec3) -> Vec3>) = if let Some(pl) = open_sketch_plane(s) {
let (c, u, v, slant, w): (Vec3, Vec3, Vec3, f64, Box<dyn Fn(Vec3) -> Vec3>) = if let Some(pl) = open_sketch_plane(s) {
let (c, u, v) = sketch_grid_frame(&pl, cam.target, major);
(c, u, v, Box::new(|p| p))
let n = u.cross(v);
(c, u, v, cam.back().dot(n), Box::new(|p| p))
} else {
let tf = cam.to_frame(cam.target);
let (u, v) = (Vec3::X, Vec3::Y);
let back = cam.to_frame(cam.back());
let n = u.cross(v);
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
(snap(u) + snap(v), u, v, Box::new(|p| cam.from_frame(p)))
(snap(u) + snap(v), u, v, back.dot(n), Box::new(|p| cam.from_frame(p)))
};
let n = ((ext / minor) as i64).clamp(1, 400);
// The grid reaches as far on the ground as the view is wide (the ground stretches as it
// is seen at a slant) and fades out with the distance, so that it dissolves into the
// distance instead of ending in a cut (#4214).
let ext = grid_ext(cam.half_height(), major, slant);
let far_reach = grid_far_reach(cam, cam.clip(sc.radius).1, w(c), w(u.cross(v)));
let fade = GridFade::new(cam.half_height(), slant, ext, far_reach);
let n = ((fade.end / minor) as i64).clamp(1, 400);
for i in -n..=n {
let t = i as f64 * minor;
let major_line = (t / major).fract().abs() < 1e-9;
let col = if major_line { p.grid_major } else { p.grid };
// The viewport's widths (pixels).
let width = if major_line { 1.0 } else { 0.7 };
sc.grid.push(SceneLine { points: vec![w(c + u * t - v * ext), w(c + u * t + v * ext)], color: col, width, on_top: false });
sc.grid.push(SceneLine { points: vec![w(c - u * ext + v * t), w(c + u * ext + v * t)], color: col, width, on_top: false });
// Two lines through the point `c + u * t`: one along v, one along u.
for (along, across) in [(v, u), (u, v)] {
for (s0, s1, alpha) in fade.pieces(t) {
sc.grid.push(SceneLine {
points: vec![w(c + across * t + along * s0), w(c + across * t + along * s1)],
color: col,
alpha,
width,
on_top: false,
});
}
}
}
}
// The origin's axes, each unless hidden one by one (`browser.visibility`), as in the viewport.
let axis = cam.half_height() * 0.25;
for ((name, d), col) in [("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().zip(p.axes) {
if guides.axes && !s.visibility.hidden_origin.iter().any(|h| h == name) {
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false });
sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, alpha: 1.0, width: 1.6, on_top: false });
}
}
let selected_occurrences: Vec<String> = s
@@ -520,8 +654,14 @@ fn build_scene(
// The body's appearance (or imported colour) and faces with looks of their own, as in
// the viewport.
let to8 = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
let color = if selected { p.body_selected } else { s.doc.body_color(body).map_or(p.body, |[r, g, b]| Rgb(to8(r), to8(g), to8(b))) };
let face_colors = if selected {
let color = if selected {
p.body_selected
} else if let Some([r, g, b]) = component_color(s, body.component) {
Rgb(r, g, b)
} else {
s.doc.body_color(body).map_or(p.body, |[r, g, b]| Rgb(to8(r), to8(g), to8(b)))
};
let face_colors = if selected || s.colored_components.is_some() {
Vec::new()
} else {
s.doc.face_colors(body).into_iter().map(|(f, l)| (f as u32, Rgb(l.color[0], l.color[1], l.color[2]))).collect()
@@ -536,19 +676,19 @@ fn build_scene(
&& s.doc.face_colors(body).iter().all(|(_, l)| l.opacity >= 0.999)
{
for (a, b) in crate::cosmetic_threads::segments_with_budget(body, &mesh, &st.threads, &mut thread_budget) {
sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, width: 0.85, on_top: false });
sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, alpha: 1.0, width: 0.85, on_top: false });
}
}
for (i, e) in mesh.edges.iter().enumerate() {
if !mesh.shaded_edge_visible(i) {
continue;
}
sc.lines.push(SceneLine { points: e.clone(), color: p.edge, width: 1.2, on_top: false });
sc.lines.push(SceneLine { points: e.clone(), color: p.edge, alpha: 1.0, width: 1.2, on_top: false });
}
// The outline of the body where the section cuts it.
if let Some((o, n)) = section {
for (a, b) in section_segments(&mesh, o, n) {
sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, width: 1.6, on_top: false });
sc.lines.push(SceneLine { points: vec![a, b], color: p.edge, alpha: 1.0, width: 1.6, on_top: false });
}
}
}
@@ -558,7 +698,7 @@ fn build_scene(
continue;
}
for (pts, col, cons) in sketch_lines_in(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, &mut sampler, p) {
sc.lines.push(SceneLine { points: pts, color: col, width: if cons { 1.0 } else { 1.6 }, on_top: active });
sc.lines.push(SceneLine { points: pts, color: col, alpha: 1.0, width: if cons { 1.0 } else { 1.6 }, on_top: active });
}
}
sc
@@ -921,7 +1061,13 @@ mod tests {
assert!(!section_segments(&s.world_state().bodies[0].mesh(), Vec3::new(0.0, 15.0, 0.0), -Vec3::Y).is_empty());
// A marker inside the body, behind the cut face.
let red = Rgb(255, 0, 0);
sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false });
sc.lines.push(SceneLine {
points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)],
color: red,
alpha: 1.0,
width: 3.0,
on_top: false,
});
let img = solidcraft_render::render(&sc, &cam, 300, 220);
let px = || img.rgba.chunks(4);
let marker = px().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
+81
View File
@@ -189,3 +189,84 @@ fn scene_sampler_uses_exact_active_occurrence_plane_and_preserves_other_scene_co
let hidden = scene_with_sketch_sampler(&s, &cam, |_, _, _| panic!("hidden sketch sampled"));
assert!(hidden.lines.iter().all(|line| !line.on_top));
}
#[test]
fn colored_components_toggle_is_stable_and_does_not_change_the_design() {
let mut s = Session::default();
let first_id = s.doc_mut().add_component(Some("First"), 0).unwrap();
let second_id = s.doc_mut().add_component(Some("Second"), 0).unwrap();
let before = serde_json::to_value(&s.doc).unwrap();
assert_eq!(component_color(&s, first_id), None);
assert_eq!(s.execute("inspect.colored_components", &json!({})).unwrap()["enabled"], true);
s.colored_components = Some(());
let first = component_color(&s, first_id);
assert!(first.is_some());
assert_ne!(first, component_color(&s, second_id));
s.execute("inspect.colored_components", &json!({"enabled": true})).unwrap();
assert_eq!(component_color(&s, first_id), first);
assert!(s.execute("inspect.colored_components", &json!({"enabled": "yes"})).is_err());
assert_eq!(component_color(&s, first_id), first);
assert_eq!(s.execute("inspect.colored_components", &json!({})).unwrap()["enabled"], false);
assert_eq!(component_color(&s, first_id), None);
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
}
#[test]
fn colored_components_override_appearances_and_restore_them() {
let mut s = Session::default();
for (name, x) in [("Left", 0), ("Right", 20)] {
s.execute("component.activate", &json!({"component": 0})).unwrap();
s.execute("component.create", &json!({"name": name})).unwrap();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})).unwrap();
}
s.selection.clear();
let cam = Camera::default();
let original = scene(&s, &cam);
s.execute("inspect.colored_components", &json!({"enabled": true})).unwrap();
s.colored_components = Some(());
let colored = scene(&s, &cam);
assert_eq!(colored.meshes.len(), 2);
assert_ne!(colored.meshes[0].color, colored.meshes[1].color);
for (body, mesh) in s.world_state().bodies.iter().zip(&colored.meshes) {
let [r, g, b] = component_color(&s, body.component).unwrap();
assert_eq!(mesh.color, Rgb(r, g, b));
assert!(mesh.face_colors.is_empty());
}
s.execute("inspect.colored_components", &json!({"enabled": false})).unwrap();
let restored = scene(&s, &cam);
for (a, b) in original.meshes.iter().zip(restored.meshes) {
assert_eq!(a.color, b.color);
assert_eq!(a.face_colors, b.face_colors);
}
}
#[test]
fn cycling_component_color_is_saved_undoable_and_shared_by_instances() {
let mut s = Session::default();
let component = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
s.execute("occurrence.copy", &json!({"component":component,"translate":[20,0,0]})).unwrap();
let appearances = s.doc.appearances.clone();
s.execute("inspect.colored_components", &json!({"enabled":true})).unwrap();
let automatic = component_color(&s, component).unwrap();
s.execute("inspect.colored_components", &json!({"enabled":false})).unwrap();
s.execute("inspect.colored_components", &json!({"enabled":true})).unwrap();
assert_eq!(component_color(&s, component), Some(automatic));
let result = s.execute("component.cycle_color", &json!({"component":component})).unwrap();
let custom = component_color(&s, component).unwrap();
assert_ne!(custom, automatic);
assert_eq!(result["color"], json!(custom));
assert_eq!(s.doc.appearances, appearances);
let loaded = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert_eq!(loaded.doc.component_identification_color(component), custom);
let copies: Vec<_> = s.world_state().bodies.iter().map(|b| b.component).collect();
assert_eq!(copies, [component, component]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(component_color(&s, component), Some(automatic));
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(component_color(&s, component), Some(custom));
s.execute("component.paste_new", &json!({"component":component,"translate":[40,0,0]})).unwrap();
assert!(s.doc.components.iter().filter(|c| c.identification_color == Some(custom)).count() >= 2);
assert!(s.execute("component.cycle_color", &json!({"component":"missing"})).is_err());
assert!(s.execute("component.cycle_color", &json!({"component":0})).is_err());
}
+69
View File
@@ -6,6 +6,75 @@ use serde_json::json;
use solidcraft_geom::Vec2;
use solidcraft_sketch::{LinkGeom, LinkKind, LinkSource};
/// #4214: the grid's fade is at full strength in the middle of the view, and gone before the
/// grid's own edge and before the picture's far plane cuts the ground, so that neither of them
/// shows as a cut.
#[test]
fn the_grid_fades_out_before_its_edge_and_before_the_far_plane() {
let fade = GridFade::new(40.0, 1.0, 120.0, 90.0);
assert!(fade.start < fade.end, "the fade has a width");
assert!(fade.end <= 90.0, "the grid is gone before the far plane cuts the ground at 90");
assert!(fade.end <= 120.0, "the grid is gone before its own edge at 120");
assert_eq!(fade.alpha(0.0), 1.0, "the middle of the grid is at full strength");
assert_eq!(fade.alpha(fade.start), 1.0, "the grid is at full strength up to {}", fade.start);
assert_eq!(fade.alpha(fade.end), 0.0, "the grid is gone at {}", fade.end);
assert_eq!(fade.alpha(fade.end * 2.0), 0.0, "the grid does not come back");
assert_eq!(fade.alpha(f64::NAN), 0.0, "a distance that is not a number fades the grid away");
// The fade falls evenly.
let mid = (fade.start + fade.end) * 0.5;
assert!((fade.alpha(mid) - 0.5).abs() < 0.01, "the fade falls evenly: {} at {mid}", fade.alpha(mid));
}
/// #4214: the headless grid fades out with the distance from the middle of the view, as the
/// viewport's does: the lines near the middle are at full strength, the ones further out are
/// dimmer, and the grid is gone before the distance where it used to stop.
#[test]
fn the_headless_grid_fades_out_with_the_distance() {
let s = Session::default();
let mut cam = home_camera(&s);
// A top view: the ground is the XY plane and the grid is centred on the target.
cam.pitch = std::f64::consts::FRAC_PI_2;
cam.target = Vec3::ZERO;
let scene = scene(&s, &cam);
assert!(!scene.grid.is_empty(), "the grid is drawn");
// How far each piece of the grid is from the middle of the view, and the alpha it is drawn
// with.
let mut pieces: Vec<(f64, f32)> = scene
.grid
.iter()
.map(|l| {
let mid = l.points[0] + (l.points[1] - l.points[0]) * 0.5;
((mid.x * mid.x + mid.y * mid.y).sqrt(), l.alpha)
})
.collect();
pieces.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
// The grid in the middle of the view is at full strength.
let strongest = pieces.iter().filter(|(d, _)| *d < 1.0).map(|(_, a)| *a).fold(0.0f32, f32::max);
assert_eq!(strongest, 1.0, "the middle of the grid is at full strength");
// The further from the middle, the dimmer: the outer half of the grid is fainter than the
// inner half.
let (near, far) = pieces.split_at(pieces.len() / 2);
let mean = |v: &[(f64, f32)]| v.iter().map(|(_, a)| f64::from(*a)).sum::<f64>() / v.len().max(1) as f64;
assert!(mean(near) > mean(far) + 0.1, "the grid fades: {} near the middle, {} far from it", mean(near), mean(far));
// The grid is gone before the distance where it used to stop, so its edge never shows.
let (_, major) = grid_step(cam.half_height());
let cut = (cam.half_height() * 3.0 / major).ceil() * major;
let farthest = pieces.last().map(|(d, _)| *d).unwrap_or(0.0);
assert!(farthest < cut, "the grid fades out at {farthest}, short of the cut at {cut}");
}
/// #4214: the grid's half size, the one the picture and the window share: the grid reaches as far
/// on its plane as the view is wide, and further as the plane is seen at a slant.
#[test]
fn the_grid_reaches_further_as_its_plane_is_seen_at_a_slant() {
let major = grid_step(40.0).1;
let flat = grid_ext(40.0, major, 1.0);
assert_eq!(flat, (40.0 * 3.0 / major).ceil() * major, "seen face on, the grid reaches as far as the view is wide");
assert!(grid_ext(40.0, major, 0.5) > flat, "seen at a slant, the grid reaches further on its plane");
// A plane seen edge-on keeps a usable size.
assert!(grid_ext(40.0, major, 0.0).is_finite());
}
/// No colour here is one of [`colors`] or an appearance below, and each field has its own.
const MARK: Palette = Palette {
background: (Rgb(1, 2, 3), Rgb(4, 5, 6)),
+3 -3
View File
@@ -430,15 +430,15 @@ pub fn scene_geometry(scene: &Scene) -> GpuScene {
}
}
}
let c4 = |c: Rgb| [c.0, c.1, c.2, 255];
let c4 = |c: Rgb, a: f32| [c.0, c.1, c.2, (a.clamp(0.0, 1.0) * 255.0).round() as u8];
for l in &scene.grid {
for w in l.points.windows(2) {
sc.under_line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32);
sc.under_line(w[0].to_f32(), w[1].to_f32(), c4(l.color, l.alpha), l.width as f32);
}
}
for l in &scene.lines {
for w in l.points.windows(2) {
sc.line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32, l.on_top);
sc.line(w[0].to_f32(), w[1].to_f32(), c4(l.color, l.alpha), l.width as f32, l.on_top);
}
}
sc
+36
View File
@@ -17,7 +17,11 @@ use wgpu::util::DeviceExt;
use crate::pbr;
mod ao;
#[cfg(not(target_arch = "wasm32"))]
mod msaa_check;
mod oit;
#[cfg(not(target_arch = "wasm32"))]
pub use msaa_check::MsaaCheck;
/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8).
pub const TRI_SIZE: usize = 28;
@@ -3481,6 +3485,38 @@ mod glass_tests {
assert!(preview[2] > preview[0] + 60, "the preview shows its own blue square: {preview:?}");
}
#[test]
fn startup_msaa_check_passes_and_detects_a_blank_resolve() {
let (adapter, device, queue) = crate::open_headless().expect("software adapter required for the startup check");
let depth = crate::depth_format(&device, true);
for format in [wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Bgra8Unorm, wgpu::TextureFormat::Bgra8UnormSrgb] {
let window = GpuTarget { format, depth: None, samples: 1, tess: false };
for samples in [1, 4] {
if ![format, depth].iter().all(|f| adapter.get_texture_format_features(*f).flags.sample_count_supported(samples)) {
continue;
}
let target = GpuTarget { depth: Some(depth), samples, ..window };
let mut renderer = Renderer::with_window_target(&device, target, window, None);
let start = std::time::Instant::now();
assert_eq!(renderer.check_msaa(&device, &queue).unwrap(), super::MsaaCheck::Passed);
eprintln!("startup check: {} {format:?} {samples}x: {:?}", adapter.get_info().name, start.elapsed());
assert!(
renderer.model.key.is_none() && renderer.highlight.key.is_none() && renderer.ss.is_none(),
"probe uploads must not replace the first real frame"
);
assert_eq!(renderer.target().samples, samples);
assert_eq!(renderer.check_msaa_with(&device, &queue, 1).unwrap(), super::MsaaCheck::FailedAtOne);
assert_eq!(renderer.target().samples, samples, "a blank 1x result must preserve the selected count");
assert_eq!(
renderer.check_msaa_with(&device, &queue, 4).unwrap(),
if samples > 1 { super::MsaaCheck::Fallback } else { super::MsaaCheck::Passed }
);
assert_eq!(renderer.target().samples, 1);
assert_eq!(renderer.check_msaa(&device, &queue).unwrap(), super::MsaaCheck::Passed);
}
}
}
/// Two see-through squares, one at z = 5 (near) and one at z = -5, in `near` and `far`
/// colours, given near first or far first.
fn two_layers(near: [u8; 4], far: [u8; 4], near_first: bool) -> GpuScene {
+202
View File
@@ -0,0 +1,202 @@
//! A startup probe of the installed viewport, including highlights, depth, resolve, and blit.
use super::*;
use crate::{GpuError, Result};
/// The startup check never changes a setting unless a working 1x replacement is proven.
#[derive(Debug, PartialEq, Eq)]
pub enum MsaaCheck {
Passed,
Fallback,
FailedAtOne,
}
impl Renderer {
/// Check a tiny scene through the installed pipelines and window format. On a blank
/// multisampled result, replace this renderer only if the same scene works at 1x.
/// Native only: browsers cannot synchronously poll a device. No result is cached on disk.
pub fn check_msaa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> Result<MsaaCheck> {
self.check_msaa_with(device, queue, 0)
}
pub(super) fn check_msaa_with(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<MsaaCheck> {
if self.probe_msaa(device, queue, blank_samples)? {
return Ok(MsaaCheck::Passed);
}
if self.target.samples == 1 {
return Ok(MsaaCheck::FailedAtOne);
}
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
let mut replacement = Self::with_window_target(device, GpuTarget { samples: 1, ..self.target }, self.window, None);
let mut error = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = crate::popped(scope.pop()) {
error.get_or_insert(e);
}
}
if let Some(error) = error {
return Err(GpuError::Render(error.to_string()));
}
if !replacement.probe_msaa(device, queue, blank_samples)? {
return Ok(MsaaCheck::FailedAtOne);
}
replacement.tess = self.tess.take();
*self = replacement;
Ok(MsaaCheck::Fallback)
}
fn probe_msaa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<bool> {
if !matches!(
self.window.format,
wgpu::TextureFormat::Rgba8Unorm
| wgpu::TextureFormat::Rgba8UnormSrgb
| wgpu::TextureFormat::Bgra8Unorm
| wgpu::TextureFormat::Bgra8UnormSrgb
) {
return Err(GpuError::Unsupported("anti-aliasing check colour format".into()));
}
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
let result = self.probe_pixels(device, queue, blank_samples);
// Discard the probe's uploads and picture even on errors, before the real scene arrives.
self.model = Batches::default();
self.sketch = Batches::default();
self.highlight = Batches::default();
self.preview = Batches::default();
self.ss = None;
self.ss_key = None;
self.ss_ready = false;
self.cached = None;
let mut error = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = crate::popped(scope.pop()) {
error.get_or_insert(e);
}
}
if let Some(error) = error {
return Err(GpuError::Render(error.to_string()));
}
result
}
fn probe_pixels(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<bool> {
const SIZE: u32 = 16;
let mut model = GpuScene::default();
for p in [[-0.9, -0.9, 0.0], [0.9, -0.9, 0.0], [0.0, 0.9, 0.0]] {
model.tri(p, [0.0, 0.0, 1.0], [255, 0, 0, 255]);
}
let mut highlight = GpuScene::default();
for p in [[-0.8, -0.8, 0.0], [0.8, -0.8, 0.0], [0.8, 0.8, 0.0], [-0.8, -0.8, 0.0], [0.8, 0.8, 0.0], [-0.8, 0.8, 0.0]] {
highlight.xray_tri(p, [0.0, 0.0, 1.0], [0, 0, 255, 64]);
}
let slot = |scene| Arc::new(Mutex::new(scene));
let frame = Frame {
product: false,
key: 1,
slot: slot(Some(model)),
sketch_key: 2,
sketch_slot: slot(None),
hl_key: 3,
hl_slot: slot(Some(highlight)),
pv_key: 4,
pv_slot: slot(None),
pv_hide: false,
view_proj: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.5, 1.0]],
back: [0.0, 0.0, 1.0],
size_px: [SIZE as f32; 2],
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
outline: false,
eye: [0.0, 0.0, 100.0, 0.0],
ao_radius: 0.0,
ssaa: 1.0,
hover_tint: [0.0; 3],
selection_tint: [0.0; 3],
};
let size = wgpu::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1 };
let color = device.create_texture(&wgpu::TextureDescriptor {
label: Some("sc_msaa_check"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.window.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = color.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
self.prepare(device, queue, &mut encoder, &frame);
// Tests reproduce a driver returning a blank resolve, without changing production draws.
#[cfg(test)]
if (blank_samples == 1 || blank_samples == self.target.samples)
&& let Some(resolve) = self.ss.as_ref().map(|ss| ss.resolve.as_ref().unwrap_or(&ss.color))
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: resolve,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
})],
..Default::default()
});
}
#[cfg(not(test))]
let _ = blank_samples;
{
let mut pass = encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
})],
..Default::default()
})
.forget_lifetime();
self.paint(&frame, &mut pass, [0.0, 0.0, SIZE as f32, SIZE as f32]);
}
// Three interior pixels in one row, padded to the copy alignment.
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_msaa_check_read"),
size: 256,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { origin: wgpu::Origin3d { x: 7, y: 8, z: 0 }, ..color.as_image_copy() },
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(256), rows_per_image: Some(1) },
},
wgpu::Extent3d { width: 3, height: 1, depth_or_array_layers: 1 },
);
queue.submit([encoder.finish()]);
let slice = read.slice(..);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// No submission index: a lost device must not turn a stale submission into a panic.
// Software adapters can take seconds on their first draw. Keep the wait bounded;
// a timeout remains an inconclusive check and preserves the selected sample count.
device
.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(10)) })
.map_err(|e| GpuError::Render(format!("anti-aliasing check wait: {e}")))?;
rx.try_recv()
.map_err(|e| GpuError::Render(format!("anti-aliasing check callback: {e}")))?
.map_err(|e| GpuError::Render(format!("anti-aliasing check map: {e}")))?;
let bytes = slice.get_mapped_range().map_err(|e| GpuError::Render(e.to_string()))?;
let bgra = matches!(self.window.format, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb);
Ok(bytes.get(..12).is_some_and(|pixels| {
pixels.as_chunks::<4>().0.iter().all(|p| {
let (red, green, blue) = if bgra { (p[2], p[1], p[0]) } else { (p[0], p[1], p[2]) };
red > 80 && red > green.saturating_add(40) && blue > 20
})
}))
}
}
+32 -2
View File
@@ -7,7 +7,7 @@
use serde_json::json;
use solidcraft_engine::Session;
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::render::{Camera, Rgb, SceneLine};
use solidcraft_engine::render::{Camera, Rgb, Scene, SceneLine};
use solidcraft_engine::view::{self, Guides, colors};
use solidcraft_gpu::GpuError;
use solidcraft_gpu::capture::{self, Capturer};
@@ -162,7 +162,13 @@ fn pictures_show_the_section_and_its_cap() {
let mut sc = view::scene(&s, &cam);
// A marker inside the body, behind the cut face.
let red = Rgb(255, 0, 0);
sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false });
sc.lines.push(SceneLine {
points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)],
color: red,
alpha: 1.0,
width: 3.0,
on_top: false,
});
let img = c.rgba(&sc, &cam, 300, 220).unwrap();
let marker = pixels(&img).filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
assert_eq!(marker, 0, "fov {fov}: the inside shows through the cut face");
@@ -226,6 +232,30 @@ fn a_body_behind_the_cut_does_not_colour_the_cap() {
}
}
/// #4214: a line that is not opaque is blended with what is under it, so the grid's fade reaches
/// the picture.
#[test]
fn pictures_blend_a_line_that_is_not_opaque() {
let Some(c) = capturer() else { return };
let cam = Camera { distance: 50.0, fov: 0.0, ..Default::default() };
let darkest = |alpha: f32| {
let mut sc = Scene { radius: 30.0, ..Default::default() };
sc.lines.push(SceneLine {
points: vec![Vec3::new(-8.0, 2.0, 0.0), Vec3::new(8.0, 2.0, 0.0)],
color: Rgb(0, 0, 0),
alpha,
width: 1.4,
on_top: false,
});
let img = c.rgba(&sc, &cam, 64, 64).unwrap();
pixels(&img).map(|p| i32::from(p[0])).min().unwrap_or(255)
};
let solid = darkest(1.0);
let half = darkest(0.5);
assert!(solid < 40, "the opaque line is black: {solid}");
assert!(half > 60 && half < 170, "the half-opaque line is between the background and the line: {half} against {solid}");
}
/// Surface analysis shades headless pictures as it shades the viewport: a box's flat faces in
/// the curvature map's flat colour, zebra stripes in black and white, the accessibility analysis
/// in reachable and blocked colours.
+2 -2
View File
@@ -153,7 +153,7 @@ fn front(mesh: Mesh, lines: Vec<SceneLine>, radius: f64, fov: f64) -> (Scene, Ca
}
fn red_line(a: Vec3, b: Vec3, on_top: bool) -> SceneLine {
SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), width: 1.2, on_top }
SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), alpha: 1.0, width: 1.2, on_top }
}
fn red_pixels(c: &Capturer, scene: &Scene, cam: &Camera) -> usize {
@@ -193,7 +193,7 @@ fn a_selected_edge_on_a_grazing_face_draws_whole() {
sc.lifted_line(a.to_f32(), b.to_f32(), [255, 0, 0, 255], 6.0, [n.to_f32(), n.to_f32()]);
sc
};
let reference = |a: Vec3, b: Vec3| vec![SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), width: 6.0, on_top: true }];
let reference = |a: Vec3, b: Vec3| vec![SceneLine { points: vec![a, b], color: Rgb(255, 0, 0), alpha: 1.0, width: 6.0, on_top: true }];
for (mode, c) in capturers() {
for tilt in [60.0_f64, 80.0, 85.0, 88.0] {
let tr = tilt.to_radians();
+2 -1
View File
@@ -35,7 +35,8 @@ fn missing(c: &Capturer, width: f64) -> Vec<usize> {
(0..N)
.filter(|&i| {
let z = -4.0 + i as f64 * 0.23;
let line = SceneLine { points: vec![Vec3::new(-8.0, 0.0, z), Vec3::new(8.0, 0.0, z)], color: Rgb(255, 0, 0), width, on_top: true };
let line =
SceneLine { points: vec![Vec3::new(-8.0, 0.0, z), Vec3::new(8.0, 0.0, z)], color: Rgb(255, 0, 0), alpha: 1.0, width, on_top: true };
let scene = Scene { lines: vec![line], radius: 30.0, ..Default::default() };
let img = c.rgba(&scene, &cam, 200, 200).unwrap();
!img.as_chunks::<4>().0.iter().any(|p| p[0] > 60 && p[0] > p[1].saturating_add(40) && p[0] > p[2].saturating_add(40))
+12
View File
@@ -46,6 +46,10 @@ mod imp {
pub fn exists(path: &str) -> bool {
check(path).is_ok() && std::path::Path::new(path).exists()
}
/// Whether the path names a regular file the caller may access.
pub fn is_file(path: &str) -> bool {
check(path).is_ok() && std::path::Path::new(path).is_file()
}
pub fn remove(path: &str) -> Result<()> {
check(path)?;
std::fs::remove_file(path)
@@ -96,6 +100,10 @@ mod imp {
pub fn exists(path: &str) -> bool {
FILES.with(|f| f.borrow().contains_key(path))
}
/// Every entry in browser storage is a regular file.
pub fn is_file(path: &str) -> bool {
exists(path)
}
pub fn remove(path: &str) -> Result<()> {
FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path))
}
@@ -451,9 +459,12 @@ mod tests {
let _ = std::fs::create_dir_all(&dir);
let p = dir.join("a.txt").to_string_lossy().to_string();
assert!(!super::exists(&p));
assert!(!super::is_file(&p));
assert!(!super::is_file(&dir.to_string_lossy()));
assert!(super::read(&p, 16).is_err());
super::write(&p, b"hello").unwrap();
assert!(super::exists(&p));
assert!(super::is_file(&p));
assert_eq!(super::len(&p).unwrap(), 5);
assert_eq!(super::read_to_string(&p, 16).unwrap(), "hello");
// The cap is the caller's, not the file's.
@@ -461,6 +472,7 @@ mod tests {
assert_eq!(super::read(&p, 5).unwrap(), b"hello");
super::remove(&p).unwrap();
assert!(!super::exists(&p));
assert!(!super::is_file(&p));
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"describe_command",
"Describe a command",
"One command's parameter docs and, for commands with a typed contract (solid.box, solid.extrude and engine.describe so far), the \
"One command's parameter docs and, for commands with a typed contract (every new command, and more of the older ones as they are converted), the \
JSON Schemas (2020-12) of its parameters (`input`) and of a successful result (`output`); null for the others.",
obj(json!({"id": string("Command id, e.g. \"solid.extrude\"")}), &["id"]),
Effect::ReadOnly,
+30 -2
View File
@@ -230,6 +230,8 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
}
for l in scene.grid.iter().chain(&scene.lines) {
let col = l.color.f();
// A line that is not opaque is blended with what is under it (the grid fading out).
let alpha = f64::from(l.alpha).clamp(0.0, 1.0);
let half = (l.width * SS as f64 * 0.5).max(0.5);
for seg in l.points.windows(2) {
let (Some(&p), Some(&q)) = (seg.first(), seg.get(1)) else { continue };
@@ -264,7 +266,7 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas {
let visible = l.on_top
|| c.depth.get(i).zip(slope.get(i)).is_some_and(|(dd, s)| z <= f64::from(*dd) + f64::from(*s) * d + z.abs() * DEPTH_EPS);
if visible {
c.put(ix as usize, iy as usize, col, cov);
c.put(ix as usize, iy as usize, col, cov * alpha);
}
}
}
@@ -428,6 +430,7 @@ mod tests {
lines: vec![SceneLine {
points: vec![Vec3::new(-8.0, 2.0, 0.0), Vec3::new(8.0, 2.0, 0.0)],
color: Rgb(0, 0, 255),
alpha: 1.0,
width: 1.4,
on_top: false,
}],
@@ -499,6 +502,30 @@ mod tests {
render(scene, cam, 64, 64).rgba.chunks(4).filter(|p| p[0] > 150 && p[1] < 100 && p[2] < 100).count()
}
/// #4214: a line that is not opaque is blended with what is under it: the grid fades out
/// with the distance, and the fade reaches the picture.
#[test]
fn a_line_that_is_not_opaque_is_blended_with_what_is_under_it() {
let line = |alpha: f32| SceneLine {
points: vec![Vec3::new(-8.0, 2.0, 0.0), Vec3::new(8.0, 2.0, 0.0)],
color: Rgb(0, 0, 255),
alpha,
width: 1.4,
on_top: false,
};
let cam = Camera { distance: 50.0, fov: 0.0, ..Default::default() };
// The blue of the line over the white background, at full strength and at half.
let blue = |alpha: f32| {
let scene = Scene { lines: vec![line(alpha)], radius: 30.0, ..Default::default() };
let img = render(&scene, &cam, 64, 64);
img.rgba.chunks(4).map(|p| p[2] as i32).max().unwrap_or(0)
};
let solid = blue(1.0);
let half = blue(0.5);
assert!(solid > 200, "the opaque line is blue: {solid}");
assert!(half < solid && half > 100, "the half-opaque line is between the background and the line: {half} against {solid}");
}
/// An edge 1 mm behind a sheet is hidden in a close view of a large design. The old test
/// allowed 2e-4 of the depth range, 2e-4 · (8 · 1200 + 2) = 1.92 mm here (orthographic; far
/// more in perspective), so it was drawn on the sheet. Now the allowance is what the sheet's
@@ -515,6 +542,7 @@ mod tests {
let edge = SceneLine {
points: vec![Vec3::new(-10.0, 0.0, 0.0) + back, Vec3::new(10.0, 0.0, 0.0) + back],
color: Rgb(255, 0, 0),
alpha: 1.0,
width: 1.2,
on_top: false,
};
@@ -534,7 +562,7 @@ mod tests {
fn edges_on_a_steep_face_draw_whole() {
let tilt = 70f64.to_radians();
let on = |x: f64, z: f64| Vec3::new(x, z * tilt.tan(), z);
let line = |points: Vec<Vec3>| SceneLine { points, color: Rgb(255, 0, 0), width: 1.2, on_top: false };
let line = |points: Vec<Vec3>| SceneLine { points, color: Rgb(255, 0, 0), alpha: 1.0, width: 1.2, on_top: false };
for fov in [0.0, 0.6] {
for radius in [30.0, 1200.0] {
let lines = vec![
+3
View File
@@ -58,6 +58,9 @@ pub const MIN_ALPHA: f64 = 0.05;
pub struct SceneLine {
pub points: Vec<Vec3>,
pub color: Rgb,
/// How opaque the line is: 1 is solid, below 1 it is blended with what is under it (the
/// grid fades out with the distance, #4214).
pub alpha: f32,
/// Width in output pixels.
pub width: f64,
/// Draw over everything (sketch geometry being edited).
@@ -0,0 +1,262 @@
//! A line and an arc that meet at two separate points tied by `Coincident` (not at one shared
//! point), plus `Tangent`. The tangency has to be read at that touch point: the touch-free form
//! (distance from the centre equals the radius) is a double root there, so it converged to only
//! sqrt(residual) of tangent and lost a row of rank when the start already was tangent.
use solidcraft_geom::Vec2;
use crate::tests::{Lcg, v};
use crate::*;
/// Circumcentre of three points.
fn circumcentre(a: Vec2, t: Vec2, b: Vec2) -> Vec2 {
let d = 2.0 * (a.x * (t.y - b.y) + t.x * (b.y - a.y) + b.x * (a.y - t.y));
let (a2, t2, b2) = (a.len().powi(2), t.len().powi(2), b.len().powi(2));
Vec2::new((a2 * (t.y - b.y) + t2 * (b.y - a.y) + b2 * (a.y - t.y)) / d, (a2 * (b.x - t.x) + t2 * (a.x - b.x) + b2 * (t.x - a.x)) / d)
}
struct Joint {
sk: Sketch,
line: usize,
arc: usize,
line_end: usize,
arc_start: usize,
}
/// Line (0,0)-(10,0) and the arc through (10,0), (15,5), (10,10), each coordinate moved by `j`.
fn joint(j: [f64; 10]) -> Joint {
let mut sk = Sketch::new();
let line = sk.add_line(v(j[0], j[1]), v(10.0 + j[2], j[3]), None, None, Some("l")).unwrap();
let (a, t, b) = (v(10.0 + j[4], j[5]), v(15.0 + j[6], 5.0 + j[7]), v(10.0 + j[8], 10.0 + j[9]));
let arc = sk.add_arc(circumcentre(a, t, b), a, b, [None, None, None], Some("a")).unwrap();
let (CurveKind::Line { b: line_end, .. }, CurveKind::Arc { a: arc_start, .. }) = (sk.curves[line].kind.clone(), sk.curves[arc].kind.clone())
else {
panic!("a line and an arc")
};
sk.add_constraint(ConstraintKind::Coincident { p: line_end, q: arc_start }, None).unwrap();
sk.add_constraint(ConstraintKind::Tangent { a: line, b: arc }, None).unwrap();
Joint { sk, line, arc, line_end, arc_start }
}
/// The centre-to-joint radius's component along the line: the tangency error in mm.
fn tangency_error(jt: &Joint) -> f64 {
let (p0, p1) = match jt.sk.curves[jt.line].kind {
CurveKind::Line { a, b } => (jt.sk.points[a].pos, jt.sk.points[b].pos),
_ => panic!("line"),
};
let d = p1 - p0;
(jt.sk.points[jt.arc_start].pos - jt.sk.center(jt.arc).unwrap()).dot(d) / d.len()
}
#[test]
fn coincident_tangent_holds_in_the_stored_points() {
let mut rng = Lcg(12345);
for amp in [0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0] {
let mut j = [0.0; 10];
for x in &mut j {
*x = rng.range(-amp, amp);
}
let mut jt = joint(j);
let rep = solve(&mut jt.sk);
assert!(rep.ok(), "amp {amp}: {rep:?}");
let err = tangency_error(&jt);
// Was 5e-5 .. 1e-4 mm with a reported residual of 1e-10.
assert!(err.abs() < 1e-8, "amp {amp}: tangency error {err}");
let gap = jt.sk.points[jt.line_end].pos.dist(jt.sk.points[jt.arc_start].pos);
assert!(gap < 1e-8, "amp {amp}: gap {gap}");
assert_eq!(rep.dof, 6, "amp {amp}: {rep:?}");
assert!(rep.redundant.is_empty(), "amp {amp}: {rep:?}");
}
}
#[test]
fn coincident_tangent_dof_does_not_depend_on_the_start() {
let rough = [0.03, -0.04, 0.02, 0.05, -0.05, 0.04, 0.04, -0.03, -0.02, 0.05];
let mut reports = Vec::new();
for j in [[0.0; 10], rough] {
let mut jt = joint(j);
reports.push(solve(&mut jt.sk));
}
for rep in &reports {
// 4 (line) + 5 (arc) - 2 (coincident) - 1 (tangent).
assert_eq!(rep.dof, 6, "{rep:?}");
assert!(rep.redundant.is_empty(), "{rep:?}");
assert!(rep.failing.is_empty(), "{rep:?}");
}
}
#[test]
fn a_coincident_chain_still_finds_the_touch_point() {
let mut jt = joint([0.03, -0.04, 0.02, 0.05, -0.05, 0.04, 0.04, -0.03, -0.02, 0.05]);
// The line end is tied to the arc start only through a third point.
let mid = jt.sk.add_point(v(10.0, 0.0), None).unwrap();
jt.sk.constraints.remove(0);
jt.sk.add_constraint(ConstraintKind::Coincident { p: jt.line_end, q: mid }, None).unwrap();
jt.sk.add_constraint(ConstraintKind::Coincident { p: mid, q: jt.arc_start }, None).unwrap();
let rep = solve(&mut jt.sk);
assert!(rep.ok(), "{rep:?}");
let err = tangency_error(&jt);
assert!(err.abs() < 1e-8, "tangency error {err}");
}
#[test]
fn a_duplicate_dimension_beside_the_tangent_joint_is_still_redundant() {
let mut jt = joint([0.0; 10]);
let l = jt.line;
jt.sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
jt.sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
let rep = solve(&mut jt.sk);
assert!(rep.ok(), "{rep:?}");
assert_eq!(rep.redundant.len(), 1, "{rep:?}");
assert_eq!(rep.dof, 5, "{rep:?}");
}
#[test]
fn conflicting_dimensions_beside_the_tangent_joint_still_fail() {
let mut jt = joint([0.0; 10]);
let l = jt.line;
jt.sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
jt.sk.add_constraint(ConstraintKind::Length { l, value: 12.0 }, None).unwrap();
let rep = solve(&mut jt.sk);
assert_eq!(rep.status, SolveStatus::Failed, "{rep:?}");
assert!(!rep.failing.is_empty(), "{rep:?}");
}
/// A line at y = 5 and a circle of radius 5 at the origin; the contact is a free point.
struct FreePoint {
sk: Sketch,
line: usize,
circle: usize,
p: usize,
}
fn free_point_contact(j: [f64; 8], tied_to_line_end: bool) -> FreePoint {
let mut sk = Sketch::new();
let line =
sk.add_line(v(-8.0 + j[0], 5.0 + j[1]), v(if tied_to_line_end { 0.0 } else { 8.0 } + j[2], 5.0 + j[3]), None, None, Some("l")).unwrap();
let circle = sk.add_circle(v(j[4], j[5]), 5.0, None, Some("c")).unwrap();
let p = sk.add_point(v(j[6], 5.0 + j[7]), None).unwrap();
let CurveKind::Line { b: line_end, .. } = sk.curves[line].kind.clone() else { panic!("line") };
if tied_to_line_end {
// S10: line end ~ free point p, p on the circle.
sk.add_constraint(ConstraintKind::Coincident { p, q: line_end }, None).unwrap();
} else {
// S2b: p on the line and on the circle.
sk.add_constraint(ConstraintKind::PointOnCurve { p, c: line }, None).unwrap();
}
sk.add_constraint(ConstraintKind::PointOnCurve { p, c: circle }, None).unwrap();
sk.add_constraint(ConstraintKind::Tangent { a: line, b: circle }, None).unwrap();
sk.add_constraint(ConstraintKind::Radius { c: circle, value: 5.0 }, None).unwrap();
FreePoint { sk, line, circle, p }
}
fn free_point_error(f: &FreePoint) -> f64 {
let CurveKind::Line { a, b } = f.sk.curves[f.line].kind else { panic!("line") };
let d = f.sk.points[b].pos - f.sk.points[a].pos;
(f.sk.points[f.p].pos - f.sk.center(f.circle).unwrap()).dot(d) / d.len()
}
#[test]
fn a_free_point_contact_is_tangent_and_counted_the_same_from_every_start() {
// 9 variables (line 4, circle 3, point 2); equations: point on circle, tangent, radius, plus either
// the coincidence (2, S10: dof 4) or the point on the line (1, S2b: dof 5).
let mut rng = Lcg(2024);
for tied in [true, false] {
for amp in [0.0, 0.01, 0.05, 0.5, 1.0] {
let mut j = [0.0; 8];
for x in &mut j {
*x = if amp == 0.0 { 0.0 } else { rng.range(-amp, amp) };
}
let mut f = free_point_contact(j, tied);
let rep = solve(&mut f.sk);
assert!(rep.ok(), "tied {tied} amp {amp}: {rep:?}");
assert_eq!(rep.dof, if tied { 4 } else { 5 }, "tied {tied} amp {amp}: {rep:?}");
assert!(rep.redundant.is_empty(), "tied {tied} amp {amp}: {rep:?}");
let err = free_point_error(&f);
assert!(err.abs() < 1e-8, "tied {tied} amp {amp}: contact perpendicular {err}");
}
}
}
#[test]
fn a_tangent_to_a_collapsed_round_curve_fails() {
// The line passes through the arc's centre while the joint is tangent: only a radius of 0 holds.
let mut jt = joint([0.03, -0.04, 0.02, 0.05, -0.05, 0.04, 0.04, -0.03, -0.02, 0.05]);
let CurveKind::Arc { c, .. } = jt.sk.curves[jt.arc].kind else { panic!("arc") };
jt.sk.add_constraint(ConstraintKind::PointOnCurve { p: c, c: jt.line }, None).unwrap();
let rep = solve(&mut jt.sk);
assert_eq!(rep.status, SolveStatus::Failed, "{rep:?}");
assert!(rep.failing.contains(&"k2".to_string()), "{rep:?}");
}
#[test]
fn an_infeasible_tangent_joint_names_the_tangent_too() {
let mut jt = joint([0.03, -0.04, 0.02, 0.05, -0.05, 0.04, 0.04, -0.03, -0.02, 0.05]);
let CurveKind::Arc { c, .. } = jt.sk.curves[jt.arc].kind else { panic!("arc") };
jt.sk.add_constraint(ConstraintKind::Radius { c: jt.arc, value: 5.0 }, None).unwrap();
jt.sk.add_constraint(ConstraintKind::PointLineDistance { p: c, l: jt.line, value: 4.0 }, None).unwrap();
let rep = solve(&mut jt.sk);
assert_eq!(rep.status, SolveStatus::Failed, "{rep:?}");
// k1 is the coincident, k2 the tangent.
assert!(rep.failing.contains(&"k2".to_string()), "{rep:?}");
}
/// The unsolved sketch of the critic's c2_t6: the tangent is violated because the Coincident is.
#[test]
fn constraint_residual_still_shows_a_violated_tying_constraint() {
let mut sk = Sketch::new();
let l = sk.add_line(v(-5.0, 3.0), v(5.0, 3.0), None, None, Some("l")).unwrap();
let c = sk.add_circle(v(0.0, 0.0), 5.0, None, Some("c")).unwrap();
let q = sk.add_point(v(0.0, 5.0), None).unwrap();
let CurveKind::Line { b: end, .. } = sk.curves[l].kind.clone() else { panic!("line") };
sk.add_constraint(ConstraintKind::PointOnCurve { p: q, c }, None).unwrap();
sk.add_constraint(ConstraintKind::Coincident { p: end, q }, None).unwrap();
sk.add_constraint(ConstraintKind::Tangent { a: l, b: c }, None).unwrap();
let r = constraint_residual(&sk, 2).unwrap();
// The distance from the centre to the line is 3 against a radius of 5.
assert!((r - 2.0).abs() < 1e-12, "{r}");
}
#[test]
fn a_midpoint_contact_is_tangent_and_counted_the_same_from_every_start() {
let mut rng = Lcg(5);
for amp in [0.0, 0.05, 0.5] {
let mut j = [0.0; 6];
for x in &mut j {
*x = if amp == 0.0 { 0.0 } else { rng.range(-amp, amp) };
}
let mut sk = Sketch::new();
let l = sk.add_line(v(-8.0 + j[0], 5.0 + j[1]), v(8.0 + j[2], 5.0 + j[3]), None, None, Some("l")).unwrap();
let c = sk.add_circle(v(j[4], j[5]), 5.0, None, Some("c")).unwrap();
let m = sk.add_point(v(0.0, 5.0), None).unwrap();
sk.add_constraint(ConstraintKind::Midpoint { p: m, l }, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: m, c }, None).unwrap();
sk.add_constraint(ConstraintKind::Tangent { a: l, b: c }, None).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok(), "amp {amp}: {rep:?}");
// 4 + 3 + 2 = 9 variables, midpoint 2, on circle 1, tangent 1.
assert_eq!(rep.dof, 5, "amp {amp}: {rep:?}");
assert!(rep.redundant.is_empty(), "amp {amp}: {rep:?}");
let CurveKind::Line { a, b } = sk.curves[l].kind else { panic!("line") };
let d = sk.points[b].pos - sk.points[a].pos;
let err = (sk.points[m].pos - sk.center(c).unwrap()).dot(d) / d.len();
assert!(err.abs() < 1e-8, "amp {amp}: {err}");
}
}
#[test]
fn a_tangent_is_never_failing_while_the_sketch_is_solved() {
let mut rng = Lcg(31);
for trial in 0..60 {
let amp = [0.05, 0.4, 1.0][trial % 3];
let mut j = [0.0; 10];
for x in &mut j {
*x = rng.range(-amp, amp);
}
let mut jt = joint(j);
let rep = solve(&mut jt.sk);
if rep.status == SolveStatus::Solved {
assert!(rep.failing.is_empty(), "trial {trial}: {rep:?}");
}
}
}
+2
View File
@@ -77,6 +77,8 @@ pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
#[cfg(test)]
mod circle_edge_tests;
#[cfg(test)]
mod coincident_tangent_tests;
#[cfg(test)]
mod construct_rounds_tests;
#[cfg(test)]
mod construct_tests;
+224 -87
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec2;
use crate::{CurveKind, Sketch, SketchError};
use crate::{ConstraintKind, Curve, CurveKind, Sketch, SketchError};
/// Placement relative to the original, along a pattern direction's oriented line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -81,6 +81,8 @@ pub struct SketchPattern {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub count_expression: Option<String>,
pub instances: Vec<PatternInstance>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub expressions: std::collections::BTreeMap<String, String>,
}
impl SketchPattern {
@@ -106,120 +108,150 @@ impl SketchPattern {
}
impl Sketch {
/// Rebuild a circular pattern after its Quantity expression changes. Surviving slots keep
/// their ids; new slots are copied from the authored seed. The caller owns the edit transaction.
/// Rebuild a circular Quantity while retaining surviving copy ids.
pub fn resize_circular_pattern(&mut self, id: &str, count: usize) -> Result<(), SketchError> {
if !(2..=500).contains(&count) {
return Err(SketchError::Invalid("circular pattern Quantity must be 2…500".into()));
}
self.validate_patterns()?;
let index = self.patterns.iter().position(|p| p.id == id).ok_or_else(|| SketchError::Invalid("missing sketch pattern".into()))?;
let mut pattern = self.patterns.remove(index);
let SketchPatternKind::Circular { center, count: previous, angle, alignment, reversed } = &mut pattern.kind else {
let mut kind = self.patterns.get(index).ok_or_else(|| SketchError::Invalid("missing sketch pattern".into()))?.kind.clone();
let SketchPatternKind::Circular { count: previous, .. } = &mut kind else {
return Err(SketchError::Invalid("Quantity expression requires a circular pattern".into()));
};
if *previous == count {
self.patterns.insert(index, pattern);
return Ok(());
}
*previous = count;
let (center_id, total, alignment, reversed) = (center.clone(), *angle, *alignment, *reversed);
for instance in pattern.instances.iter().filter(|i| i.index[0] >= count) {
self.update_pattern(index, kind)
}
/// Rebuild a pattern after its driving expressions change, retaining surviving copy ids.
pub fn update_pattern(&mut self, index: usize, kind: SketchPatternKind) -> Result<(), SketchError> {
let old = self.patterns.get(index).cloned().ok_or_else(|| SketchError::Invalid("missing sketch pattern".into()))?;
let check_count = |n: usize| {
if !(1..=500).contains(&n) { Err(SketchError::Invalid("pattern count must be a positive integer from 1 to 500".into())) } else { Ok(()) }
};
match &kind {
SketchPatternKind::Circular { count, angle, .. } => {
check_count(*count)?;
if !angle.is_finite() || angle.abs() <= 1e-9 || angle.abs() > 360.0 {
return Err(SketchError::Invalid("pattern angle must be nonzero and at most 360 degrees".into()));
}
}
SketchPatternKind::Rectangular { count, count2, spacing, spacing2, .. } => {
check_count(*count)?;
check_count(*count2)?;
if count.checked_mul(*count2).is_none_or(|n| n > 500) {
return Err(SketchError::Invalid("pattern counts must make at most 500 instances total".into()));
}
if ![spacing, spacing2].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) {
return Err(SketchError::Invalid("pattern spacing must be finite and in range".into()));
}
}
}
self.patterns.remove(index);
let entity_ids = old.entities.clone();
let (center, slots) = match &kind {
SketchPatternKind::Circular { center, count, angle, alignment, reversed } => {
let center = self.resolve_point(center).ok_or_else(|| SketchError::Invalid("missing pattern center".into()))?;
let full = (angle.abs() - 360.0).abs() < 1e-9;
let step = angle / if full { *count as f64 } else { (count.max(&2) - 1) as f64 };
(
Some(center),
(1..*count).map(|i| ([i, 0], (step * alignment.multiplier(i, *reversed)).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
)
}
SketchPatternKind::Rectangular { count, count2, .. } => {
(None, (0..*count).flat_map(|i| (0..*count2).filter_map(move |j| (i != 0 || j != 0).then_some(([i, j], 0.0, Vec2::ZERO)))).collect())
}
};
let center_id = center.and_then(|i| self.points.get(i)).map(|p| p.id.clone());
let mut existing = old.instances.clone();
// Remove retired instances first: all following indices are resolved by stable id.
for instance in existing.iter().filter(|instance| !slots.iter().any(|(index, _, _)| *index == instance.index)) {
let curves: Vec<_> = instance.curves.iter().filter_map(|id| self.curve_index(id)).collect();
self.constraints.retain(|c| !instance.constraints.contains(&c.id));
self.remove_curves(&curves);
let points: Vec<_> = instance.points.iter().filter_map(|(_, id)| self.resolve_point(id)).collect();
self.remove_points(&points);
}
pattern.instances.retain(|i| i.index[0] < count);
let center = self.resolve_point(&center_id).ok_or_else(|| SketchError::Invalid("missing pattern center".into()))?;
let origin = self.point(center).ok_or_else(|| SketchError::Invalid("missing pattern center".into()))?;
let step = total / if (total.abs() - 360.0).abs() < 1e-9 { count as f64 } else { (count - 1) as f64 };
for slot in 1..count {
let angle = (step * alignment.multiplier(slot, reversed)).to_radians();
let (sin, cos) = angle.sin_cos();
let transform = |p: Vec2| {
let d = p - origin;
origin + Vec2::new(d.x * cos - d.y * sin, d.x * sin + d.y * cos)
};
if let Some(instance) = pattern.instances.iter().find(|i| i.index == [slot, 0]) {
if let SketchPatternKind::Rectangular { direction, direction2, .. } = &kind {
for id in direction.iter().chain(direction2) {
if self.curve_index(id).is_none() {
return Err(SketchError::Invalid("the direction belongs to an instance removed by this edit".into()));
}
}
}
let mut instances = Vec::new();
let center = center_id.as_ref().and_then(|id| self.resolve_point(id));
if center_id.is_some() && center.is_none() {
return Err(SketchError::Invalid("the center belongs to an instance removed by this edit".into()));
}
for (index, angle, offset) in slots {
let (base_offset, directions) = linear_displacement(self, &kind, index, offset);
let c = center.and_then(|i| self.point(i)).unwrap_or(Vec2::ZERO);
let mut displacement = base_offset;
for (line, distance) in directions.into_iter().flatten() {
if let Some(crate::Shape::Line { a, b }) = self.shape(line)
&& let Some(u) = (b - a).normalized()
{
displacement += u * distance.x + u.perp() * distance.y;
}
}
let transform = |p| rotate(c, angle)(p) + displacement;
let instance = if let Some(i) = existing.iter().position(|instance| instance.index == index) {
let instance = existing.remove(i);
for cid in &instance.constraints {
if let Some(crate::ConstraintKind::PatternPoint { angle: value, .. }) =
self.constraints.iter_mut().find(|c| c.id == *cid).map(|c| &mut c.kind)
if let Some(constraint) = self.constraints.iter_mut().find(|constraint| constraint.id == *cid)
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, directions: dirs, .. } = &mut constraint.kind
{
*value = angle;
*slot = center;
*a = angle;
*off = base_offset;
*dirs = directions;
}
}
for (seed, copy) in &instance.points {
let p =
self.resolve_point(seed).and_then(|p| self.point(p)).ok_or_else(|| SketchError::Invalid("missing pattern seed".into()))?;
let q = self
.resolve_point(copy)
.and_then(|p| self.points.get_mut(p))
.ok_or_else(|| SketchError::Invalid("missing pattern copy".into()))?;
q.pos = transform(p);
let pos =
self.resolve_point(seed).and_then(|i| self.point(i)).ok_or_else(|| SketchError::Invalid("missing seed point".into()))?;
if let Some(point) = self.resolve_point(copy).and_then(|i| self.points.get_mut(i)) {
let at = transform(pos);
if !crate::model::in_range(at) {
return Err(SketchError::Invalid("pattern point is out of range".into()));
}
point.pos = at;
}
}
continue;
}
let curves: Vec<_> = pattern.entities.iter().filter_map(|id| self.curve_index(id)).collect();
let mut points = Vec::new();
for id in &pattern.entities {
if let Some(c) = self.curve_index(id).and_then(|i| self.curves.get(i)) {
points.extend(c.kind.point_ids());
} else if let Some(p) = self.resolve_point(id) {
points.push(p);
} else {
return Err(SketchError::Invalid("missing pattern entity".into()));
instance
} else {
let cs: Vec<_> = entity_ids.iter().filter_map(|id| self.curve_index(id)).collect();
let ps: Vec<_> = entity_ids.iter().filter(|id| self.curve_index(id).is_none()).filter_map(|id| self.resolve_point(id)).collect();
let (made, map) = copy_through(self, &cs, &ps, &transform)?;
let mut cons = Vec::new();
for (p, q) in &map {
cons.push(
self.add_constraint(ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset: base_offset, directions }, None)?,
);
}
}
let mut map = Vec::new();
for p in points {
if map.iter().any(|(seed, _)| *seed == p) {
continue;
for (a, b) in cs.iter().zip(&made) {
if matches!(self.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
cons.push(self.add_constraint(ConstraintKind::PatternRadius { a: *a, b: *b }, None)?);
}
}
let pos = self.point(p).ok_or_else(|| SketchError::Invalid("missing pattern point".into()))?;
map.push((p, self.add_point(transform(pos), None)?));
}
if self.curves.len().saturating_add(curves.len()) > crate::MAX_CURVES {
return Err(SketchError::TooLarge);
}
let mut made = Vec::new();
for source in &curves {
let mut copy = self.curves.get(*source).cloned().ok_or_else(|| SketchError::Invalid("missing pattern curve".into()))?;
copy.kind.map_points(&|p| map.iter().find(|(seed, _)| *seed == p).map_or(p, |(_, q)| *q));
let prefix = match copy.kind {
CurveKind::Line { .. } => "l",
CurveKind::Circle { .. } => "c",
CurveKind::Arc { .. } => "a",
CurveKind::Ellipse { .. } => "e",
CurveKind::Spline { .. } => "s",
CurveKind::Conic { .. } => "k",
};
copy.id = self.fresh(prefix)?;
copy.link = None;
copy.fixed = false;
copy.centerline = false;
made.push(copy.id.clone());
self.curves.push(copy);
}
let mut constraints = Vec::new();
for (p, q) in &map {
constraints.push(self.add_constraint(
crate::ConstraintKind::PatternPoint { p: *p, q: *q, center: Some(center), angle, offset: Vec2::ZERO, directions: [None; 2] },
None,
)?);
}
for (source, copy) in curves.iter().zip(&made) {
if matches!(self.curves.get(*source).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
let b = self.curve_index(copy).ok_or_else(|| SketchError::Invalid("missing pattern curve".into()))?;
constraints.push(self.add_constraint(crate::ConstraintKind::PatternRadius { a: *source, b }, None)?);
PatternInstance {
index,
curves: made.iter().filter_map(|i| self.curves.get(*i).map(|c| c.id.clone())).collect(),
points: map.iter().filter_map(|(p, q)| Some((self.points.get(*p)?.id.clone(), self.points.get(*q)?.id.clone()))).collect(),
constraints: cons,
}
}
let points = map.iter().filter_map(|(p, q)| Some((self.points.get(*p)?.id.clone(), self.points.get(*q)?.id.clone()))).collect();
pattern.instances.push(PatternInstance { index: [slot, 0], curves: made, points, constraints });
};
instances.push(instance);
}
self.patterns.insert(index.min(self.patterns.len()), pattern);
self.validate_patterns()
self.patterns.insert(index.min(self.patterns.len()), SketchPattern { kind, instances, ..old });
self.follow_pattern_seeds();
Ok(())
}
/// Start an explicit Move/Scale of seed geometry with its copies already transformed.
@@ -343,6 +375,15 @@ impl Sketch {
}
let mut ids = std::collections::BTreeSet::new();
for pattern in &self.patterns {
if pattern.expressions.len() > 5
|| pattern.expressions.iter().any(|(key, expression)| {
!matches!(key.as_str(), "count" | "count2" | "spacing" | "spacing2" | "angle")
|| expression.trim().is_empty()
|| expression.len() > 4096
})
{
return Err(SketchError::Invalid("invalid sketch pattern expression".into()));
}
if pattern.count_expression.as_ref().is_some_and(|e| e.trim().is_empty() || e.len() > 4096)
|| (pattern.count_expression.is_some() && !matches!(pattern.kind, SketchPatternKind::Circular { .. }))
{
@@ -424,3 +465,99 @@ impl Sketch {
Ok(())
}
}
/// Keep sampled vector directions compatible with older designs, while picked lines are
/// solver references. The default second direction follows the first line's perpendicular.
fn linear_displacement(sk: &Sketch, kind: &SketchPatternKind, [i, j]: [usize; 2], fallback: Vec2) -> (Vec2, [Option<(usize, Vec2)>; 2]) {
let SketchPatternKind::Rectangular { dir, dir2, direction, direction2, spacing, spacing2, alignment, alignment2, reversed, reversed2, .. } = kind
else {
return (fallback, [None; 2]);
};
let mut offset = Vec2::ZERO;
let mut directions = [None; 2];
let d1 = *spacing * alignment.multiplier(i, *reversed);
let d2 = *spacing2 * alignment2.multiplier(j, *reversed2);
if let Some(l) = direction.as_ref().and_then(|id| sk.curve_index(id)) {
directions[0] = Some((l, Vec2::new(d1, if dir2.is_none() && direction2.is_none() { d2 } else { 0.0 })));
} else {
offset += *dir * d1;
if dir2.is_none() && direction2.is_none() {
offset += dir.perp() * d2;
}
}
if let Some(l) = direction2.as_ref().and_then(|id| sk.curve_index(id)) {
directions[1] = Some((l, Vec2::new(d2, 0.0)));
} else if let Some(dir2) = dir2 {
offset += *dir2 * d2;
}
(offset, directions)
}
fn involved_points(sk: &Sketch, cs: &[usize], ps: &[usize]) -> Vec<usize> {
let mut v: Vec<usize> = Vec::new();
for c in cs {
if let Some(k) = sk.curves.get(*c).map(|c| &c.kind) {
v.extend(k.point_ids());
}
}
v.extend(ps);
let mut out = Vec::new();
for q in v {
if !out.contains(&q) {
out.push(q);
}
}
out
}
/// Copy seed curves and their points, returning curves and the original-to-copy point map.
fn copy_through(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Vec2) -> Vec2) -> Result<(Vec<usize>, Vec<(usize, usize)>), SketchError> {
if sk.curves.len().saturating_add(cs.len()) > crate::MAX_CURVES {
return Err(crate::SketchError::TooLarge);
}
let pts = involved_points(sk, cs, ps);
let mut map: Vec<(usize, usize)> = Vec::new();
for q in &pts {
let pos = sk.point(*q).unwrap_or_default();
let n = sk.add_point(f(pos), None)?;
map.push((*q, n));
}
let m = |q: usize| map.iter().find(|x| x.0 == q).map(|x| x.1).unwrap_or(q);
let mut made = Vec::new();
for c in cs {
let Some(cu) = sk.curves.get(*c).cloned() else { continue };
let mut kind = cu.kind.clone();
kind.map_points(&m);
let prefix = match &kind {
CurveKind::Ellipse { .. } => "e",
CurveKind::Spline { .. } => "s",
CurveKind::Conic { .. } => "k",
CurveKind::Line { .. } => "l",
CurveKind::Circle { .. } => "c",
CurveKind::Arc { .. } => "a",
};
let id = sk.fresh(prefix)?;
// Copies retain the seed line's extent.
let infinite = cu.infinite && matches!(kind, CurveKind::Line { .. });
sk.curves.push(Curve {
id,
kind,
construction: cu.construction,
reversed: cu.reversed,
link: None,
centerline: false,
fixed: false,
infinite,
});
made.push(sk.curves.len() - 1);
}
Ok((made, map))
}
fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
move |q: Vec2| {
let d = q - c;
let (s, co) = ang.sin_cos();
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
}
}
+44
View File
@@ -1172,9 +1172,53 @@ fn rejoined(segs: Vec<Seg2>, joins: &impl Fn(&Seg2, &Seg2) -> bool) -> Vec<Seg2>
out
}
/// `regions` with each region that repeats an earlier one (the same boundary within a small
/// tolerance) left out.
fn without_repeats(regions: &[Region2]) -> Vec<Region2> {
// Comparing every pair is quadratic: beyond this many the regions are kept as they are.
const MAX_COMPARED: usize = 5000;
if regions.len() < 2 || regions.len() > MAX_COMPARED {
return regions.to_vec();
}
let keys: Vec<(f64, Vec2, f64)> =
regions.iter().map(|r| (r.outer.signed_area().abs(), r.outer.centroid(), r.outer.segs.iter().map(Seg2::length).sum::<f64>())).collect();
let mut kept: Vec<usize> = Vec::new();
for (i, r) in regions.iter().enumerate() {
let Some(&(area, centroid, size)) = keys.get(i) else { continue };
let tol = 1e-7 * (1.0 + size);
let repeat = kept.iter().any(|&k| {
keys.get(k).is_some_and(|&(a, c, _)| (a - area).abs() <= tol * (1.0 + size) && c.dist(centroid) <= tol)
&& regions.get(k).is_some_and(|other| same_region(r, other, tol))
});
if !repeat {
kept.push(i);
}
}
kept.iter().filter_map(|&i| regions.get(i).cloned()).collect()
}
/// Whether two regions have the same boundary within `tol`: the same outer loop and the same
/// holes, whichever segment each loop starts with.
fn same_region(a: &Region2, b: &Region2, tol: f64) -> bool {
let on = |p: Vec2, l: &Loop2| l.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) <= tol);
// Each loop's corners and segment middles lie on the other loop.
let covers = |x: &Loop2, y: &Loop2| x.segs.iter().all(|s| on(s.start(), y) && on(s.mid(), y));
let same_loop = |x: &Loop2, y: &Loop2| {
(x.signed_area().abs() - y.signed_area().abs()).abs() <= tol * (1.0 + x.segs.iter().map(Seg2::length).sum::<f64>())
&& covers(x, y)
&& covers(y, x)
};
a.holes.len() == b.holes.len() && same_loop(&a.outer, &b.outer) && a.holes.iter().all(|h| b.holes.iter().any(|g| same_loop(h, g)))
}
/// Union of regions that touch along shared boundary segments (one extrude of several adjacent
/// profiles makes one body). Disjoint regions stay separate.
pub fn merge_regions(regions: &[Region2]) -> Vec<Region2> {
// A region drawn again on top of another is one region (#5543): a circle drawn twice at one
// place is two profiles, and extruded as two coincident tools a cut through a grid of holes
// with four such circles retried with the tool nudged and took fifty times as long.
let distinct = without_repeats(regions);
let regions = distinct.as_slice();
let tol = MERGE_TOL * 10.0;
// Every boundary segment oriented with the region on its left.
let mut segs: Vec<Seg2> = Vec::new();
+8 -1
View File
@@ -195,8 +195,15 @@ pub fn constraint_residual(sk: &Sketch, ci: usize) -> Option<f64> {
Some(p) => Block::TangentAt(ci, &c.kind, p),
None => Block::User(ci, &c.kind),
};
let state = State { sk, lay: &fixed, x: &[] };
let mut r = Vec::new();
block_residuals(&State { sk, lay: &fixed, x: &[] }, &b, &mut r);
block_residuals(&state, &b, &mut r);
// A tangency read at its touch point holds there when the curves only touch at a point that
// is not on both: the touch-free form (distance from the centre equals the radius) shows a
// violated tying constraint as well, so the larger of the two is the constraint's distance.
if matches!(b, Block::TangentAt(..)) {
super::residuals(&state, &c.kind, &mut r);
}
Some(max_abs(&r))
}
+101 -1
View File
@@ -781,7 +781,72 @@ pub(crate) fn touch_point(sk: &Sketch, k: &ConstraintKind, on_curve: &[(usize, u
return Some(*p);
}
let on = |p: usize, c: usize| on_curve.binary_search(&(p, c)).is_ok();
ea.iter().find(|p| on(**p, b)).or_else(|| eb.iter().find(|p| on(**p, a))).copied()
if let Some(p) = ea.iter().find(|p| on(**p, b)).or_else(|| eb.iter().find(|p| on(**p, a))) {
return Some(*p);
}
contact_through_coincident(sk, a, b, &ea, &eb, on_curve)
}
/// Whether curve `c` is a straight line.
fn is_straight(sk: &Sketch, c: usize) -> bool {
matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }))
}
/// A touch point the two curves reach through `Coincident` constraints, when they share no point
/// and no end of one is a point-on-curve of the other: a point lying on `a` (an end of it, or a
/// point-on-curve of it) tied by a chain of `Coincident` constraints to a point lying on `b`
/// (the chain may be empty: a free point on both curves). That covers two ends tied together, a
/// line end tied to a free point that is on the circle, and a free point on both curves.
/// Free-form curves count only their ends. Without this the tangency falls back to the
/// touch-free form (distance from the centre equals the radius), whose Jacobian is
/// rank-deficient at the solution: the touch point is a double root of it, so it converges to
/// only sqrt(residual) of tangent and drops rank (see the `coincident_tangent_*` tests). The
/// point returned is the one lying on the round curve (for a free-form curve, its end), so the
/// radius to it is read from the curve it belongs to.
fn contact_through_coincident(sk: &Sketch, a: usize, b: usize, ea: &[usize], eb: &[usize], on_curve: &[(usize, usize)]) -> Option<usize> {
let n = sk.points.len();
let free = |c: usize| sk.curves.get(c).is_some_and(|c| c.kind.is_freeform());
// Points lying on a curve: its ends, and (unless free-form) the points constrained onto it.
let lying = |c: usize, ends: &[usize]| -> Vec<usize> {
let mut v: Vec<usize> = ends.to_vec();
if !free(c) {
v.extend(on_curve.iter().filter(|(_, cc)| *cc == c).map(|(p, _)| *p));
// The midpoint of a line lies on it.
v.extend(sk.constraints.iter().filter(|k| !k.driven).filter_map(|k| match k.kind {
ConstraintKind::Midpoint { p, l } if l == c => Some(p),
_ => None,
}));
}
v.retain(|p| *p < n);
v
};
let (on_a, on_b) = (lying(a, ea), lying(b, eb));
if on_a.is_empty() || on_b.is_empty() {
return None;
}
let mut parent: Vec<usize> = (0..n).collect();
for c in sk.constraints.iter().filter(|c| !c.driven) {
if let ConstraintKind::Coincident { p, q } = c.kind
&& p != q
&& p < n
&& q < n
{
union(&mut parent, p, q);
}
}
for &p in &on_a {
let root = find(&mut parent, p);
if let Some(&q) = on_b.iter().find(|q| find(&mut parent, **q) == root) {
return Some(if free(a) {
p
} else if free(b) || is_straight(sk, a) {
q
} else {
p
});
}
}
None
}
/// Variables a block depends on, and whether it reads a point that is a constant of the solve.
@@ -2467,6 +2532,11 @@ fn solve_inner(
let mut det = vec![false; lay.n];
let mut rank = 0;
let mut redundant: Vec<String> = Vec::new();
let mut degenerate = false;
// Tangents whose touch-free form is violated (or whose round curve collapsed): named in
// `failing` only when the sketch failed anyway, so a sketch inside the acceptance tolerance
// is never `Solved` with a constraint in `failing`.
let mut named: Vec<String> = Vec::new();
// A component too large to solve is a failure, not an allocation.
let too_large = comps.iter().filter(|(_, vars, bis)| !bis.is_empty() && oversized(vars)).map(|(_, vars, _)| vars.len()).max();
if too_large.is_some() {
@@ -2494,6 +2564,26 @@ fn solve_inner(
}
let (r, _, owner) = system(sk, &lay, &sub, &mut x, false);
check(&r, &owner, &sub, &mut failing);
// A tangency read at its touch point can hold there while the curves are not tangent (the
// touch point is not on the line, or not on the round curve): that failure is named by the
// constraint that ties the point, and the tangent is named as well when the touch-free
// form (distance from the centre equals the radius) is violated. A tangent whose round
// curve has collapsed to a point holds trivially and is a degenerate answer: it fails.
let now = State { sk, lay: &lay, x: &x };
for (b, _) in &sub {
let (Block::User(ci, k) | Block::TangentAt(ci, k, _)) = b else { continue };
let ConstraintKind::Tangent { a: ta, b: tb } = **k else { continue };
let Some(c) = sk.constraints.get(*ci) else { continue };
let mut touch_free = Vec::new();
if matches!(b, Block::TangentAt(..)) {
residuals(&now, k, &mut touch_free);
}
let collapsed = [ta, tb].iter().any(|&cv| !now.is_line(cv) && !now.is_freeform(cv) && now.radius_of(cv) < ACCEPT);
if (collapsed || touch_free.iter().any(|v| !(v.abs() < ACCEPT))) && !named.contains(&c.id) {
named.push(c.id.clone());
}
degenerate |= collapsed;
}
if analysis == Analysis::StatusOnly {
continue;
}
@@ -2545,6 +2635,9 @@ fn solve_inner(
let (r, _, owner) = system(sk, &lay, &consts, &mut x, false);
check(&r, &owner, &consts, &mut failing);
}
if degenerate {
max_residual = max_residual.max(1.0);
}
// Weld the whole connected closure graph, preserving every immutable node.
// Validate the actual prospective geometry before accepting a numerical solve.
let has_periodic = sk.curves.iter().any(|c| matches!(c.kind, CurveKind::Spline { periodic: true, .. }));
@@ -2600,6 +2693,13 @@ fn solve_inner(
None
};
let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed };
if status == SolveStatus::Failed {
for id in named {
if !failing.contains(&id) {
failing.push(id);
}
}
}
for id in &folded {
if !failing.contains(id) {
failing.push(id.clone());
+3
View File
@@ -106,6 +106,8 @@ pub struct AgentConfig {
/// API: the endpoint (`…/v1`) and model.
pub base_url: String,
pub model: String,
/// Added with the Ollama preset (even if its display name changes).
pub ollama: bool,
/// API: a key is stored for it in the system keychain.
pub key: bool,
/// API: how long one model request may take, in seconds (clamped to
@@ -122,6 +124,7 @@ impl Default for AgentConfig {
command: String::new(),
base_url: String::new(),
model: String::new(),
ollama: false,
key: false,
request_timeout_s: DEFAULT_REQUEST_TIMEOUT_S,
}
+5 -2
View File
@@ -183,6 +183,7 @@ impl Worker {
if let Err(e) = self.start_mcp() {
self.emit(Event::Error(e));
}
self.emit(Event::ToolsAvailable(!self.tools.is_empty()));
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
// The endpoint's models for the picker; an endpoint that does not list them shows none.
if let Ok(list) = self.model_list() {
@@ -939,7 +940,8 @@ mod tests {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
assert!(matches!(events.first(), Some(Event::Ready { .. })), "{events:?}");
assert_eq!(events.first(), Some(&Event::ToolsAvailable(false)));
assert!(events.iter().any(|e| matches!(e, Event::Ready { .. })), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::Tool { status: Some(ToolStatus::Failed), .. })), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Done.")), "{events:?}");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
@@ -1336,7 +1338,8 @@ mod tests {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
assert!(matches!(events.first(), Some(Event::Ready { .. })), "{events:?}");
assert_eq!(events.first(), Some(&Event::ToolsAvailable(false)));
assert!(events.iter().any(|e| matches!(e, Event::Ready { .. })), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::Tool { status: Some(ToolStatus::Failed), .. })), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Done.")), "{events:?}");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
+2
View File
@@ -34,6 +34,8 @@ pub enum PromptPart {
pub enum Event {
/// Whether the agent takes documents (PDFs) in a message; sent before [`Event::Ready`].
Documents(bool),
/// Whether SolidCraft tools were offered by the backend.
ToolsAvailable(bool),
/// Ready for a message. `agent`: its name and version; `images`: it takes pictures.
Ready { agent: String, images: bool },
/// Text it streams (`thought`: its reasoning, shown dimmed).
+83
View File
@@ -352,10 +352,93 @@ fn detect_in(dirs: &[PathBuf], home: Option<&Path>) -> Detection {
d
}
/// Query only the endpoint's origin; OpenAI-compatible paths such as `/v1` are unrelated.
#[cfg(not(target_arch = "wasm32"))]
fn api_origin(base_url: &str) -> Option<String> {
let (scheme, rest) = base_url.trim().split_once("://")?;
if !matches!(scheme, "http" | "https") {
return None;
}
let authority = rest.split(['/', '?', '#']).next()?;
if authority.is_empty() || authority.contains(['@', ' ']) || authority.len() > 2048 {
return None;
}
Some(format!("{scheme}://{authority}"))
}
/// Blocking, best-effort Ollama probe. Call only on a background thread, without API keys.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn ollama_tools(http: &super::backend::HttpFn, base_url: &str, model: &str, known_ollama: bool) -> Option<bool> {
use super::backend::HttpRequest;
let origin = api_origin(base_url)?;
let request = |method, path: &str, body| {
let (status, bytes) = http(&HttpRequest {
method,
url: format!("{origin}/api/{path}"),
headers: if method == "POST" { vec![("Content-Type".into(), "application/json".into())] } else { Vec::new() },
body,
timeout_s: 5,
})
.ok()?;
if !(200..300).contains(&status) || bytes.len() > 4 * 1024 * 1024 {
return None;
}
serde_json::from_slice::<serde_json::Value>(&bytes).ok()
};
if !known_ollama {
let version = request("GET", "version", None)?;
version.get("version")?.as_str()?;
}
let body = serde_json::to_vec(&serde_json::json!({"model": model})).ok()?;
tools_capability(&request("POST", "show", Some(body))?)
}
/// Tool support is unknown unless Ollama returns a capabilities list of strings.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn tools_capability(value: &serde_json::Value) -> Option<bool> {
let capabilities = value.get("capabilities")?.as_array()?;
if capabilities.iter().any(|capability| !capability.is_string()) {
return None;
}
Some(capabilities.iter().any(|capability| capability.as_str() == Some("tools")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ollama_capabilities_distinguish_tools_chat_and_unknown() {
use serde_json::json;
assert_eq!(tools_capability(&json!({"capabilities": ["completion", "tools"]})), Some(true));
assert_eq!(tools_capability(&json!({"capabilities": ["completion"]})), Some(false));
assert_eq!(tools_capability(&json!({"capabilities": []})), Some(false));
for value in [json!({}), json!({"capabilities": null}), json!({"capabilities": [false]})] {
assert_eq!(tools_capability(&value), None);
}
assert_eq!(api_origin("http://[::1]:11434/proxy/v1/").as_deref(), Some("http://[::1]:11434"));
assert_eq!(api_origin("file:///tmp/model"), None);
let http: super::super::backend::HttpFn = std::sync::Arc::new(|request| {
assert_eq!(request.timeout_s, 5);
match request.method {
"GET" => {
assert_eq!(request.url, "http://localhost:11434/api/version");
Ok((200, br#"{"version":"0.0"}"#.to_vec()))
}
"POST" => {
assert_eq!(request.url, "http://localhost:11434/api/show");
assert_eq!(serde_json::from_slice::<serde_json::Value>(request.body.as_ref().unwrap()).unwrap(), json!({"model":"chat:4b"}));
Ok((200, br#"{"capabilities":["completion"]}"#.to_vec()))
}
_ => Err("unexpected HTTP method".into()),
}
});
assert_eq!(ollama_tools(&http, "http://localhost:11434/custom/v1", "chat:4b", false), Some(false));
assert_eq!(ollama_tools(&http, "http://localhost:11434/v1", "chat:4b", true), Some(false));
let unavailable: super::super::backend::HttpFn = std::sync::Arc::new(|_| Err("unavailable".into()));
assert_eq!(ollama_tools(&unavailable, "http://localhost:11434/v1", "chat:4b", false), None);
}
#[test]
fn a_program_is_found_in_the_first_folder_that_has_it() {
let root = std::env::temp_dir().join(format!("sc-agent-find-{}", std::process::id()));
+60
View File
@@ -253,6 +253,10 @@ struct Turn {
changes: u32,
requests: u32,
limit_told: bool,
tools_available: bool,
tool_called: bool,
replied: bool,
edit_requested: bool,
label: String,
}
@@ -390,6 +394,9 @@ pub struct AgentChat {
running_endpoint: String,
backend: Option<Box<dyn ChatBackend>>,
turn: Option<Turn>,
tools_available: Option<bool>,
/// The latest completed turn's notice; older notices remain in the transcript.
turn_warning: Option<String>,
held: Vec<Held>,
next_approval: u64,
/// The chat's control endpoint: the port, and its requests.
@@ -564,6 +571,7 @@ impl AgentChat {
fn on_event(&mut self, e: Event) -> Option<Outcome> {
match e {
Event::Documents(documents) => self.documents = documents,
Event::ToolsAvailable(available) => self.tools_available = Some(available),
Event::Ready { agent, images } => {
self.agent = agent;
self.images = images;
@@ -579,6 +587,12 @@ impl AgentChat {
}
}
Event::Text { thought, text } => {
if !thought
&& !text.trim().is_empty()
&& let Some(turn) = self.turn.as_mut()
{
turn.replied = true;
}
if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut()
&& *t == thought
{
@@ -588,6 +602,9 @@ impl AgentChat {
}
}
Event::Tool { id, title, kind, status, output } => {
if let Some(turn) = self.turn.as_mut() {
turn.tool_called = true;
}
let existing = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id: x, .. } if *x == id));
if let Some(Item::Tool { title: t, kind: k, status: s, output: o, .. }) = existing {
if let Some(v) = title {
@@ -822,6 +839,7 @@ pub fn connect(app: &mut SolveApp, ctx: &egui::Context) {
chat.notice(tr("ui.agent_chat_no_cli"), true);
}
let bridge = cli.zip(chat.tools_port).map(|(cli, port)| acp::McpBridge { cli, port });
chat.tools_available = Some(bridge.is_some());
let wake = ctx.clone();
let start = backend::StartContext { cwd: working_dir(), bridge, tools: chat.tools_tx.clone(), wake: Box::new(move || wake.request_repaint()) };
let started: Result<Box<dyn ChatBackend>, String> = match entry.kind {
@@ -1106,6 +1124,7 @@ pub fn new_chat(app: &mut SolveApp) {
let chat = &mut app.agent_chat;
chat.confirm_new = false;
chat.items.clear();
chat.turn_warning = None;
chat.gallery = None;
chat.picked.clear();
chat.picked_context = Context::default();
@@ -1188,6 +1207,7 @@ fn start_turn_with(app: &mut SolveApp, text: String, image: bool, at: Option<usi
// The undo step is named after the message, or its first file when it has no words.
let first_line = text.lines().find(|l| !l.trim().is_empty()).or_else(|| files.first().map(|f| f.name.as_str())).unwrap_or("");
let label: String = first_line.chars().take(48).collect();
app.agent_chat.turn_warning = None;
app.agent_chat.turn = Some(Turn {
active: Arc::new(std::sync::atomic::AtomicBool::new(true)),
start_doc: app.session.doc.clone(),
@@ -1196,6 +1216,10 @@ fn start_turn_with(app: &mut SolveApp, text: String, image: bool, at: Option<usi
requests: 0,
limit_told: false,
label,
tools_available: app.agent_chat.tools_available.unwrap_or(app.agent_chat.tools_port.is_some()),
tool_called: false,
replied: false,
edit_requested: requests_edit(&text),
});
app.agent_chat.last = Some((text, image, at, files));
app.agent_chat.state = Some(State::Busy);
@@ -1408,6 +1432,24 @@ fn approve_with(app: &mut SolveApp, id: u64, allow: bool, why: &str) {
}
}
/// Recognise only explicit English edit requests; informational questions stay quiet.
/// This checks the user's opening words, never interprets the model's prose as a call.
fn requests_edit(text: &str) -> bool {
let opening: String = text.trim().chars().take(160).collect::<String>().to_ascii_lowercase();
let mut words = opening.as_str();
for prefix in ["please ", "can you ", "could you ", "would you ", "please "] {
if let Some(rest) = words.strip_prefix(prefix) {
words = rest;
}
}
[
"add", "create", "make", "change", "rename", "move", "rotate", "delete", "remove", "sketch", "extrude", "round", "fillet", "chamfer",
"scale", "cut", "drill",
]
.iter()
.any(|verb| words.strip_prefix(*verb).is_some_and(|rest| rest.starts_with(char::is_whitespace)))
}
/// The message is answered: its changes become one undo step, and what still waits is declined.
fn end_turn(app: &mut SolveApp) {
decline_all(app, "the message ended");
@@ -1418,6 +1460,20 @@ fn finish_turn(chat: &mut AgentChat, session: &mut solidcraft_engine::Session) {
let Some(turn) = chat.turn.take() else { return };
turn.active.store(false, std::sync::atomic::Ordering::Relaxed);
let steps = merge_undo(&mut session.undo, &turn.start_doc, turn.start_undo, &trf("ui.agent_chat_undo_label", &[("message", &turn.label)]));
if chat.ended.as_deref() == Some("")
&& !chat.stopped
&& turn.tools_available
&& !turn.tool_called
&& turn.requests == 0
&& turn.changes == 0
&& steps == 0
&& Arc::ptr_eq(&session.doc, &turn.start_doc)
&& (!turn.replied || turn.edit_requested)
{
let warning = tr("ui.agent_chat_no_tool_call").to_string();
chat.turn_warning = Some(warning.clone());
chat.notice(warning, false);
}
if steps > 0 {
let doc = session.doc.clone();
chat.push(Item::Changes { steps, doc, undone: false });
@@ -1614,6 +1670,8 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) {
start_turn(app, text, image, at, files);
}
}
#[cfg(not(target_arch = "wasm32"))]
settings::poll_capabilities(app, ctx);
pump_tools(app);
if !app.agent_chat.held.is_empty() || app.agent_chat.state() == State::Busy {
ctx.request_repaint_after(std::time::Duration::from_millis(250));
@@ -1964,6 +2022,7 @@ pub fn status(app: &SolveApp) -> Value {
"model": c.model,
"providers": model_providers(app).iter().map(|(name, count)| json!({"name": name, "count": count})).collect::<Vec<_>>(),
"provider": shown_provider(app),
"modelWarning": settings::model_warning(app),
"modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
"mode": c.mode,
"open": c.open,
@@ -1987,6 +2046,7 @@ pub fn status(app: &SolveApp) -> Value {
"loading": c.loading.iter().map(|f| f.name.clone()).collect::<Vec<_>>(),
"stopped": c.stopped,
"turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})),
"turnWarning": c.turn_warning,
"detection": detection,
"items": items,
})
+4 -1
View File
@@ -1163,6 +1163,9 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
});
if let Some(warning) = settings::model_warning(app) {
ui.add(egui::Label::new(RichText::new(warning).color(t.warning).size(t.font_small)).wrap());
}
ui.horizontal(|ui| {
let b = if app.agent_chat.state().running() {
ui_kit::button(ui, tr("ui.agent_chat_stop"), ui_kit::Button::Normal).on_hover_text(tr("ui.agent_chat_stop_tip"))
@@ -1469,7 +1472,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
}
Item::Notice { text, error } => {
ui_kit::assistant_banner(ui, if *error { ui_kit::AssistantTone::Error } else { ui_kit::AssistantTone::Neutral }, |ui| {
ui.label(RichText::new(text).color(if *error { t.error } else { t.text_dim }));
ui.add(egui::Label::new(RichText::new(text).color(if *error { t.error } else { t.text_dim })).wrap());
});
}
}
+142
View File
@@ -54,6 +54,92 @@ impl Test {
pub struct State {
pub add: Option<AddState>,
pub tests: std::collections::HashMap<String, Test>,
#[cfg(not(target_arch = "wasm32"))]
capabilities: std::collections::HashMap<(String, String), Option<bool>>,
#[cfg(not(target_arch = "wasm32"))]
checking: Option<CapabilityCheck>,
}
#[cfg(not(target_arch = "wasm32"))]
struct CapabilityCheck {
key: (String, String),
rx: std::sync::mpsc::Receiver<Option<bool>>,
}
/// Poll one bounded background probe at a time. Failures are cached too.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn poll_capabilities(app: &mut SolveApp, ctx: &egui::Context) {
use std::sync::mpsc::TryRecvError;
let state = &mut app.agent_chat.settings;
if let Some(check) = &state.checking {
let result = match check.rx.try_recv() {
Ok(value) => Some(value),
Err(TryRecvError::Disconnected) => Some(None),
Err(TryRecvError::Empty) => None,
};
if let Some(value) = result {
if state.capabilities.len() >= 128 {
state.capabilities.clear();
}
state.capabilities.insert(check.key.clone(), value);
state.checking = None;
}
}
if state.checking.is_some() {
return;
}
let Some((key, known)) = capability_selection(app) else { return };
if app.agent_chat.settings.capabilities.contains_key(&key) {
return;
}
let Some(http) = app.services.agent_http.clone() else { return };
if app.agent_chat.settings.capabilities.len() >= 128 {
app.agent_chat.settings.capabilities.clear();
}
let (tx, rx) = std::sync::mpsc::channel();
let query = key.clone();
let wake = ctx.clone();
if std::thread::Builder::new()
.name("model-capabilities".into())
.spawn(move || {
let _ = tx.send(discover::ollama_tools(&http, &query.0, &query.1, known));
wake.request_repaint();
})
.is_ok()
{
app.agent_chat.settings.checking = Some(CapabilityCheck { key, rx });
} else {
app.agent_chat.settings.capabilities.insert(key, None);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn capability_selection(app: &SolveApp) -> Option<((String, String), bool)> {
let entry = agents::current(app)?;
if entry.kind != Kind::Api {
return None;
}
let model = if app.agent_chat.backend.is_none() || app.agent_chat.model.is_empty() { entry.model } else { app.agent_chat.model.clone() };
if model.is_empty() || model.len() > 4096 || entry.base_url.len() > 4096 {
return None;
}
let known = app.preferences.agents.iter().any(|a| a.id == entry.id && (a.ollama || a.name.eq_ignore_ascii_case("Ollama")));
Some(((entry.base_url, model), known))
}
/// The same advisory text is used by the panel and the control channel.
pub(super) fn model_warning(app: &SolveApp) -> Option<String> {
#[cfg(not(target_arch = "wasm32"))]
if let Some((key, _)) = capability_selection(app) {
return capability_warning(&key.1, app.agent_chat.settings.capabilities.get(&key).copied().flatten());
}
let _ = app;
None
}
#[cfg(not(target_arch = "wasm32"))]
fn capability_warning(model: &str, tools: Option<bool>) -> Option<String> {
(tools == Some(false)).then(|| trf("ui.agent_chat_model_no_tools", &[("model", model)]))
}
/// Open the Add agent dialog.
@@ -645,6 +731,7 @@ fn add_agent(app: &mut SolveApp) -> Result<(), String> {
}
AddKind::Api => {
c.kind = s.protocol.kind();
c.ollama = s.protocol == Protocol::OpenAi && s.preset == 0;
c.base_url = agents::normalize_url(&s.base_url)?;
c.model = s.model.trim().to_string();
c.request_timeout_s = s.request_timeout_s.clamp(agents::MIN_REQUEST_TIMEOUT_S, agents::MAX_REQUEST_TIMEOUT_S);
@@ -676,6 +763,61 @@ mod card_tests {
use crate::scenario::{self, Harness};
use serde_json::json;
#[test]
fn cached_model_warning_follows_endpoint_model_and_agent_selection() {
let mut h = Harness::new();
h.app.agent_chat.detection = Some(Default::default());
h.app.preferences.agents.push(AgentConfig {
id: "local".into(),
kind: Kind::Api,
base_url: "http://localhost:11434/v1".into(),
model: "chat:4b".into(),
..Default::default()
});
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::time::{Duration, Instant};
let calls = Arc::new(AtomicUsize::new(0));
let count = calls.clone();
let ui_thread = std::thread::current().id();
h.app.services.agent_http = Some(Arc::new(move |request| {
assert_ne!(std::thread::current().id(), ui_thread);
count.fetch_add(1, Ordering::SeqCst);
Ok((200, if request.method == "GET" { br#"{"version":"0.0"}"#.to_vec() } else { br#"{"capabilities":["completion"]}"#.to_vec() }))
}));
poll_capabilities(&mut h.app, &h.ctx);
let deadline = Instant::now() + Duration::from_secs(2);
while h.app.agent_chat.settings.checking.is_some() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
poll_capabilities(&mut h.app, &h.ctx);
}
assert!(h.app.agent_chat.settings.checking.is_none());
assert_eq!(calls.load(Ordering::SeqCst), 2);
poll_capabilities(&mut h.app, &h.ctx);
assert_eq!(calls.load(Ordering::SeqCst), 2, "cached selections do not probe again");
assert_eq!(status(&h.app)["modelWarning"], capability_warning("chat:4b", Some(false)).unwrap());
h.app.preferences.agents[0].model = "tools:8b".into();
assert!(status(&h.app)["modelWarning"].is_null());
h.app.preferences.agents[0].model = "chat:4b".into();
h.app.preferences.agents[0].base_url = "http://localhost:1234/v1".into();
assert!(status(&h.app)["modelWarning"].is_null());
h.app.preferences.agents[0].base_url = "http://localhost:11434/v1".into();
h.app.preferences.agents[0].kind = Kind::Acp;
assert!(status(&h.app)["modelWarning"].is_null());
}
#[test]
fn model_warning_only_for_explicitly_missing_tools() {
assert_eq!(
capability_warning("local:4b", Some(false)).as_deref(),
Some("local:4b can't use SolidCraft's tools, so it can only chat. Pick a model with tool support.")
);
assert_eq!(capability_warning("local:4b", Some(true)), None);
assert_eq!(capability_warning("local:4b", None), None);
}
#[test]
fn add_agent_card_retains_kind_handles_draft_and_validation() {
let mut h = Harness::new();
+65
View File
@@ -2382,3 +2382,68 @@ fn add_agent_takes_a_request_timeout_and_the_state_reports_it() {
control(&mut app, &ctx, &json!({"add_agent": {"id": "acp", "name": "A", "command": "x --acp"}})).unwrap();
assert_eq!(timeout(&mut app, "acp"), Some(Value::Null), "only endpoints have a request timeout");
}
#[test]
fn no_tool_turn_reports_empty_reply_and_unperformed_edit() {
for (prompt, reply) in [
("Hello", ""),
("Hello", " "),
("Make a box", "Here is how to make a box."),
("Can you rename the body?", "I cannot rename bodies."),
("Could you please make a change to the current shape", "Here is the command."),
("Sketch a rectangle", r#"{"name":"batch","arguments":{"commands":[]}}"#),
] {
let (mut app, _, _) = app_with_script();
app.agent_chat.tools_port = Some(12345);
start_turn(&mut app, prompt.into(), false, None, vec![]);
if !reply.is_empty() {
app.agent_chat.on_event(Event::Text { thought: false, text: reply.into() });
}
app.agent_chat.on_event(Event::TurnEnded { reason: String::new() });
end_turn(&mut app);
let expected = tr("ui.agent_chat_no_tool_call");
assert!(app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { text, error: false } if text == expected)), "{prompt}");
assert_eq!(status(&app)["turnWarning"], expected);
assert!(app.session.undo.is_empty());
start_turn(&mut app, "What is a fillet?".into(), false, None, vec![]);
assert!(status(&app)["turnWarning"].is_null());
assert!(app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { text, .. } if text == expected)));
new_chat(&mut app);
assert!(status(&app)["turnWarning"].is_null());
assert!(app.agent_chat.items.is_empty());
}
}
#[test]
fn no_tool_turn_warning_does_not_follow_a_tool_call() {
let (mut app, _, _) = app_with_script();
app.agent_chat.tools_port = Some(12345);
start_turn(&mut app, "Make a box".into(), false, None, vec![]);
app.agent_chat.on_event(Event::Tool { id: "t".into(), title: Some("batch".into()), kind: None, status: Some(ToolStatus::Failed), output: None });
app.agent_chat.on_event(Event::TurnEnded { reason: String::new() });
end_turn(&mut app);
assert!(status(&app).get("turnWarning").is_some());
assert!(status(&app)["turnWarning"].is_null());
assert!(!app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { .. })));
}
#[test]
fn no_tool_turn_warning_leaves_answers_and_incomplete_turns_quiet() {
for (prompt, reply, reason, tools) in [
("What is a fillet?", "A fillet rounds an edge.", "", true),
("How do I make a box?", "Use the Box command.", "", true),
("Make a box", "", "cancelled", true),
("Make a box", "", "error", true),
("Make a box", "", "", false),
] {
let (mut app, _, _) = app_with_script();
app.agent_chat.tools_port = tools.then_some(12345);
start_turn(&mut app, prompt.into(), false, None, vec![]);
app.agent_chat.on_event(Event::Text { thought: false, text: reply.into() });
app.agent_chat.on_event(Event::TurnEnded { reason: reason.into() });
end_turn(&mut app);
assert!(status(&app).get("turnWarning").is_some());
assert!(status(&app)["turnWarning"].is_null(), "{prompt}: {reason}");
assert!(!app.agent_chat.items.iter().any(|i| matches!(i, Item::Notice { text, .. } if text == tr("ui.agent_chat_no_tool_call"))));
}
}
+370 -247
View File
@@ -7,6 +7,9 @@
//! ([`crate::context_menu`]).
use crate::ui_kit::{Container, Surface};
#[path = "browser_cache.rs"]
mod cache;
#[cfg(test)]
#[path = "occurrence_ui_tests.rs"]
mod occurrence_ui_tests;
@@ -145,6 +148,22 @@ pub struct TreeState {
/// The previous frame's complete row order: a Shift click resolves its range here, since
/// this frame's `order` does not yet hold the rows below the clicked one (#4991).
order_prev: Vec<String>,
order_len: usize,
cache: Option<cache::Cache>,
}
impl TreeState {
fn remember(&mut self, key: &str) {
if let Some(slot) = self.order.get_mut(self.order_len) {
if slot != key {
slot.clear();
slot.push_str(key);
}
} else {
self.order.push(key.to_owned());
}
self.order_len += 1;
}
}
/// Moving an occurrence: the offsets typed so far (pending until Capture Position).
@@ -324,6 +343,10 @@ const INDENT: f32 = 14.0;
/// Opacity of a dimmed row's icon (a hidden body, sketch, origin item, …).
const DIM_ICON_OPACITY: f32 = 0.4;
fn row_rect(ui: &egui::Ui) -> Rect {
Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H))
}
/// One row's look.
#[derive(Default)]
pub(crate) struct Row<'a> {
@@ -379,7 +402,7 @@ pub(crate) struct RowResp {
pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
let t = Tokens::get();
// Rows scrolled out of view only take their space: no widgets, no painting.
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H));
let r = row_rect(ui);
if !ui.is_rect_visible(r) {
ui.allocate_space(r.size());
return RowResp { rect: Some(r), ..Default::default() };
@@ -512,9 +535,10 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
fn click_select(app: &mut SolveApp, ui: &egui::Ui, key: String, sel: Option<Sel>) {
let mods = ui.input(|i| i.modifiers);
if mods.shift
&& let Some(a) = app.tree.anchor.clone()
&& let Some(a) = app.tree.anchor.as_deref()
{
let items = shift_range(&app.tree.order_prev, &a, &key).or_else(|| shift_range(&app.tree.order, &a, &key));
let order = app.tree.order.get(..app.tree.order_len).unwrap_or(&[]);
let items = shift_range(&app.tree.order_prev, a, &key).or_else(|| shift_range(order, a, &key));
if let Some(items) = items {
let _ = app.run("select.set", json!({ "items": items, "add": mods.command || mods.ctrl }));
return;
@@ -626,6 +650,7 @@ pub fn apply_fold(app: &mut SolveApp, open: bool, open_by_default: &[String], cl
struct Actions {
menu: Option<(Pos2, Target)>,
hover_bodies: Vec<String>,
hovered: bool,
drop: Option<DropAction>,
/// What the selection and the 3D view's pointer light in the tree.
lit: Lit,
@@ -639,8 +664,10 @@ enum Thing {
Sketch(u64),
/// An origin or construction plane, by name.
Plane(String),
/// An origin axis.
/// An origin or construction axis.
Axis(String),
/// A construction point.
Point(String),
}
/// What the tree lights for the selection and the 3D view's pointer: each lit item's row (in
@@ -679,6 +706,7 @@ impl Lit {
}
Sel::Plane { name } => Thing::Plane(name.clone()),
Sel::Axis { name } => Thing::Axis(name.clone()),
Sel::ConstructionPoint { name } => Thing::Point(name.clone()),
Sel::Occurrence { .. } => continue,
});
}
@@ -689,6 +717,7 @@ impl Lit {
) => Some(Thing::Sketch(*sketch)),
Some(Hit::Plane { name, .. }) => Some(Thing::Plane(name.clone())),
Some(Hit::Axis { name }) => Some(Thing::Axis(name.clone())),
Some(Hit::ConstructionPoint { name }) => Some(Thing::Point(name.clone())),
None => None,
};
let doc = app.session.doc.clone();
@@ -703,10 +732,20 @@ impl Lit {
Thing::Sketch(id) => {
components.extend(doc.features.iter().find(|f| f.id == *id && matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.component))
}
Thing::Plane(name) => components.extend(
doc.features.iter().find(|f| f.name == *name && matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.component),
Thing::Plane(name) | Thing::Axis(name) | Thing::Point(name) => components.extend(
doc.features
.iter()
.find(|f| {
f.name == *name
&& matches!(
f.kind,
FeatureKind::ConstructionPlane { .. }
| FeatureKind::ConstructionAxis { .. }
| FeatureKind::ConstructionPoint { .. }
)
})
.map(|f| f.component),
),
Thing::Axis(_) => {}
}
}
Lit { selected, hovered, paths, components }
@@ -721,25 +760,16 @@ impl Lit {
(self.selected.contains(thing), self.hovered.as_ref() == Some(thing))
}
/// The things a folder entry stands for.
fn entry_things(folder: &str, e: &Entry) -> Vec<Thing> {
match folder {
"bodies" => e.bodies.iter().cloned().map(Thing::Body).collect(),
"sketches" => e.key.parse().ok().map(Thing::Sketch).into_iter().collect(),
_ => vec![Thing::Plane(e.label.clone())],
}
}
/// Whether a folder entry is lit: (selected, under the pointer). Its own `selected` (picked
/// in the tree, or the sketch being edited) counts as selected.
fn entry(&self, folder: &str, e: &Entry) -> (bool, bool) {
let things = Lit::entry_things(folder, e);
(e.selected || things.iter().any(|t| self.selected.contains(t)), things.iter().any(|t| self.hovered.as_ref() == Some(t)))
fn entry(&self, app: &SolveApp, e: &Entry) -> (bool, bool) {
let (selected, hovered) = self.thing(&e.thing);
(e.is_selected(app) || selected, hovered)
}
/// The mark of a row holding these entries: a faint bar when any of them is lit.
fn branch<'e>(&self, folder: &str, entries: impl IntoIterator<Item = &'e Entry>) -> Mark {
if entries.into_iter().any(|e| self.entry(folder, e) != (false, false)) { Mark::Branch } else { Mark::None }
fn branch<'e>(&self, app: &SolveApp, entries: impl IntoIterator<Item = &'e Entry>) -> Mark {
if entries.into_iter().any(|e| self.entry(app, e) != (false, false)) { Mark::Branch } else { Mark::None }
}
/// The mark of a placement row: a faint bar when a lit body is placed in it or below it, or
@@ -755,6 +785,24 @@ fn lit_row((selected, hovered): (bool, bool)) -> (bool, Mark) {
(selected, if selected || hovered { Mark::Here } else { Mark::None })
}
impl Actions {
fn hover<'a>(&mut self, bodies: impl Iterator<Item = &'a String>) {
let mut len = 0;
for body in bodies {
if let Some(slot) = self.hover_bodies.get_mut(len) {
if slot != body {
slot.clone_from(body);
}
} else {
self.hover_bodies.push(body.clone());
}
len += 1;
}
self.hover_bodies.truncate(len);
self.hovered = true;
}
}
/// How narrow the browser sidebar may be dragged, and how wide (the viewport keeps the rest).
const MIN_BROWSER_WIDTH: f32 = 170.0;
const MAX_BROWSER_WIDTH: f32 = 620.0;
@@ -785,10 +833,11 @@ pub(crate) fn resize_column(app: &mut SolveApp, ui: &egui::Ui, rect: Rect, width
pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
let available = ui.available_rect_before_wrap();
let t = Tokens::get();
app.tree.order_prev = std::mem::take(&mut app.tree.order);
std::mem::swap(&mut app.tree.order_prev, &mut app.tree.order);
app.tree.order_len = 0;
DRAWN.with(|d| d.borrow_mut().clear());
OCCURRENCES.with(|d| d.borrow_mut().clear());
let mut acts = Actions::default();
let mut acts = Actions { hover_bodies: std::mem::take(&mut app.viewport.hover_bodies), ..Default::default() };
let stacked = crate::timeline_left::stacked(app);
let container =
Container::new(ui.ctx(), Surface::Panel).joined(crate::ui_kit::DockEdges { left: true, top: true, bottom: stacked, ..Default::default() });
@@ -969,6 +1018,7 @@ fn model_placement(app: &mut SolveApp, path: &[u64]) -> Option<std::sync::Arc<so
app.tree.model.as_mut()?.placement(&app.session, path)
}
#[cfg(test)]
fn model_children(app: &SolveApp, component: u64) -> Vec<u64> {
match app.tree.model.as_ref().filter(|model| model.matches(&app.session)) {
Some(model) => model.children.get(&component).cloned().unwrap_or_default(),
@@ -1002,8 +1052,14 @@ fn model_feature_indices(app: &SolveApp, component: u64, sketches: bool) -> Vec<
.iter()
.enumerate()
.filter_map(|(i, f)| {
let kind =
if sketches { matches!(f.kind, FeatureKind::Sketch { .. }) } else { matches!(f.kind, FeatureKind::ConstructionPlane { .. }) };
let kind = if sketches {
matches!(f.kind, FeatureKind::Sketch { .. })
} else {
matches!(
f.kind,
FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. }
)
};
(f.component == component && kind).then_some(i)
})
.collect(),
@@ -1023,6 +1079,8 @@ fn browser_rows_hugging(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Action
fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shrink_height: bool) {
prepare_model(app);
acts.lit = Lit::of(app);
let mut cache = app.tree.cache.take().unwrap_or_default();
cache.prepare(app);
egui::ScrollArea::vertical()
// The card may animate all the way down to its header.
.min_scrolled_height(0.0)
@@ -1030,14 +1088,19 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr
.scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, mouse_wheel: true, ..egui::scroll_area::ScrollSource::NONE })
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
component_rows(app, ui, 0, 0, acts);
component_rows(app, ui, 0, 0, acts, &mut cache);
});
// The row was reached and scrolled to, or isn't in the tree (a body a later feature removes):
// either way the reveal is done.
app.tree.reveal = None;
app.tree.cache = Some(cache);
}
fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, acts: Actions) {
fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, mut acts: Actions) {
app.tree.order.truncate(app.tree.order_len);
if !acts.hovered {
acts.hover_bodies.clear();
}
app.viewport.hover_bodies = acts.hover_bodies;
if let Some(d) = acts.drop {
apply_drop(app, d);
@@ -1062,28 +1125,40 @@ fn component_name(app: &SolveApp, id: u64) -> String {
crate::dialogs_part_size::browser_label(app, id, &name)
}
fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, acts: &mut Actions) {
let t = Tokens::get();
let key = format!("c{id}");
let bodies = crate::context_menu::component_bodies(app, id);
if let Some(r) = app.tree.reveal.clone()
&& (bodies.contains(&r) || model_children(app, id).into_iter().any(|o| reveals_in_occurrence(app, &[o])))
fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, acts: &mut Actions, cache: &mut cache::Cache) {
let mut root = std::mem::take(&mut cache.root);
component_rows_inner(app, ui, id, depth, acts, cache, &mut root);
cache.root = root;
}
fn component_rows_inner(
app: &mut SolveApp,
ui: &mut egui::Ui,
id: u64,
depth: usize,
acts: &mut Actions,
cache: &mut cache::Cache,
root: &mut cache::Node,
) {
let key = &root.key;
if let Some(body) = app.tree.reveal.as_ref()
&& (cache.root_bodies.contains(body) || root.children.iter().any(|path| reveals_in_occurrence(app, path)))
{
app.tree.collapsed.remove(&key);
app.tree.collapsed.remove(key);
}
let open = is_open(app, &key, true);
let open = is_open(app, key, true);
let active = app.session.active_component == id;
let visible = bodies.is_empty() || bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b));
let grounded = app.session.doc.occurrence_of(id).is_some_and(|o| o.grounded);
let bodies = &cache.root_bodies;
let visible = id == 0 || bodies.is_empty() || bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b));
let faded = app.session.active_component != 0 && !app.session.doc.component_within(id, app.session.active_component) && !active;
let name = component_name(app, id);
let badges: &[&str] = if grounded { &["pin"] } else { &[] };
let name = &root.name;
let badges: &[&str] = if root.grounded { &["pin"] } else { &[] };
let row = Row {
depth,
fold: Some(open),
eye: (id != 0).then_some(visible),
icon: "component",
label: &name,
label: name,
name: true,
selected: app.tree.picked_components.contains(&id),
dim: faded,
@@ -1095,7 +1170,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
};
let r = draw_row(ui, ui.id().with(("comp", id)), &row);
if r.fold {
toggle(app, &key, true);
toggle(app, key, true);
}
if r.radio {
let _ = app.run("component.activate", json!({ "component": id }));
@@ -1130,7 +1205,7 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
}
}
if r.hovered {
acts.hover_bodies = bodies.clone();
acts.hover(bodies.iter());
}
if let Some(p) = r.secondary {
acts.menu = Some((p, Target::Component { id }));
@@ -1152,64 +1227,83 @@ fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize,
canvas_rows(app, ui, d, acts);
analysis_rows(app, ui, d, acts);
}
let _ = t;
for folder in ["bodies", "sketches", "construction"] {
folder_rows(app, ui, id, folder, d, acts);
for (slot, folder) in root.folders.iter_mut().zip(["bodies", "sketches", "construction"]) {
let data = slot.get_or_insert_with(|| cache.folder(app, id, None, folder));
folder_rows_at(app, ui, id, None, folder, d, acts, data);
}
features::rows(app, ui, id, &[], d, acts);
if id == 0 {
crate::dialogs_assembly::browser_joints(app, ui, d);
}
let children = model_children(app, id);
for occurrence in children {
occurrence_rows(app, ui, vec![occurrence], d, acts);
for path in &root.children {
occurrence_rows(app, ui, path, d, acts, cache);
}
}
fn occurrence_key(path: &[u64]) -> String {
format!("o:{}", path.iter().map(u64::to_string).collect::<Vec<_>>().join("/"))
use std::fmt::Write;
let mut key = String::from("o:");
for (i, id) in path.iter().enumerate() {
if i != 0 {
key.push('/');
}
let _ = write!(key, "{id}");
}
key
}
/// A placement, rather than a component definition: repeated child IDs remain distinct
/// because their complete paths are the selection and row identity.
fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth: usize, acts: &mut Actions) {
let Some(placement) = model_placement(app, &path) else { return };
let key = occurrence_key(&path);
if reveals_in_occurrence(app, &path) {
app.tree.collapsed.remove(&key);
fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: &[u64], depth: usize, acts: &mut Actions, cache: &mut cache::Cache) {
// Own this cached row while drawing: children can populate the cache without copying it.
let Some((path, mut placement)) = cache.take_occurrence(app, path) else { return };
occurrence_rows_inner(app, ui, &path, depth, acts, cache, &mut placement);
cache.occurrences.insert(path, placement);
}
fn occurrence_rows_inner(
app: &mut SolveApp,
ui: &mut egui::Ui,
path: &[u64],
depth: usize,
acts: &mut Actions,
cache: &mut cache::Cache,
placement: &mut cache::Node,
) {
let key = &placement.key;
if reveals_in_occurrence(app, path) {
app.tree.collapsed.remove(key);
}
let open = is_open(app, &key, true);
let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice())
|| (app.session.active_occurrence_path.is_none()
&& app.session.active_component == placement.component
&& app.session.doc.first_path(placement.component) == path);
let visible = app.session.occurrence_visible(&path);
let own_visible = !app.session.occurrence_hidden(&path);
let selected = app.session.selection.contains(&Sel::Occurrence { path: path.clone() });
let faded = app.session.active_occurrence_path.as_ref().is_some_and(|p| !path.starts_with(p) && !p.starts_with(&path));
let open = is_open(app, key, true);
let on_screen = ui.is_rect_visible(row_rect(ui));
let active = app.session.active_occurrence_path.as_deref() == Some(path)
|| (app.session.active_occurrence_path.is_none() && app.session.active_component == placement.component && placement.first);
let visible = !on_screen || app.session.occurrence_visible(path);
let own_visible = !on_screen || !app.session.occurrence_hidden(path);
let selected = on_screen && app.session.selection.iter().any(|s| matches!(s, Sel::Occurrence { path: selected } if selected == path));
let faded = app.session.active_occurrence_path.as_ref().is_some_and(|p| !path.starts_with(p) && !p.starts_with(path));
let badges: &[&str] = if placement.grounded { &["pin"] } else { &[] };
let label = crate::dialogs_part_size::browser_label(app, placement.component, &placement.name);
app.tree.order.push(key.clone());
app.tree.remember(key);
let r = draw_row(
ui,
ui.id().with(&key),
ui.id().with(key),
&Row {
depth,
fold: Some(open),
eye: Some(own_visible),
icon: "component",
label: &label,
label: &placement.label,
name: true,
selected,
dim: faded || !visible,
radio: Some(active),
badges,
mark: acts.lit.placement(&app.session.doc, &path, placement.component),
mark: acts.lit.placement(&app.session.doc, path, placement.component),
..Default::default()
},
);
if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) {
OCCURRENCES.with(|d| d.borrow_mut().push((path.clone(), placement.name.clone(), rect, open)));
OCCURRENCES.with(|d| d.borrow_mut().push((path.to_vec(), placement.name.clone(), rect, open)));
crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(rect.right() - 12.0, rect.center().y));
let x = rect.left() + 2.0 + depth as f32 * INDENT;
for (control, offset) in [("fold", 6.0), ("eye", 22.0), ("activate", 38.0)] {
@@ -1217,24 +1311,19 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth:
}
}
if let Some(resp) = &r.resp {
let definition = app.session.doc.components.iter().find(|c| c.id == placement.component).map(|c| c.name.as_str()).unwrap_or("");
let location = path
.iter()
.filter_map(|id| app.session.doc.occurrences.iter().find(|o| o.id == *id))
.map(|o| o.name.as_str())
.collect::<Vec<_>>()
.join(" / ");
let inherited = if own_visible && !visible { "\nHidden by a parent placement. Show the parent to reveal this instance." } else { "" };
resp.clone().on_hover_text(format!(
"Component: {definition}\nPlacement: {}\nGeometry edits apply to every instance of this component.{inherited}",
location
));
resp.clone().on_hover_ui(|ui| {
ui.label(&placement.tooltip);
if !inherited.is_empty() {
ui.label(inherited.trim_start());
}
});
}
if r.fold {
toggle(app, &key, true);
toggle(app, key, true);
}
if r.eye {
let _ = app.run("browser.visibility", json!({"visible": app.session.occurrence_hidden(&path), "paths": [path]}));
let _ = app.run("browser.visibility", json!({"visible": app.session.occurrence_hidden(path), "paths": [path]}));
}
if r.radio || (r.double && !app.session.doc.parts.contains_key(&placement.component)) {
let _ = app.run("component.activate", json!({"component": placement.component, "occurrence": placement.occurrence, "path": path}));
@@ -1243,16 +1332,17 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth:
crate::dialogs_part_size::open(app, placement.component);
}
if r.clicked {
click_select(app, ui, key.clone(), Some(Sel::Occurrence { path: path.clone() }));
click_select(app, ui, key.clone(), Some(Sel::Occurrence { path: path.to_vec() }));
}
if r.hovered {
acts.hover_bodies = app.session.occurrence_bodies(&path, true).unwrap_or_default();
let bodies = placement.hover_bodies.get_or_insert_with(|| app.session.occurrence_bodies(path, true).unwrap_or_default());
acts.hover(bodies.iter());
}
if let Some(p) = r.secondary {
if !selected {
let _ = app.run("select.set", json!({"items": [Sel::Occurrence { path: path.clone() }]}));
let _ = app.run("select.set", json!({"items": [Sel::Occurrence { path: path.to_vec() }]}));
}
acts.menu = Some((p, Target::Occurrence { path: path.clone() }));
acts.menu = Some((p, Target::Occurrence { path: path.to_vec() }));
}
if let Some(resp) = &r.resp
&& let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp != placement.component && d.folder != "construction", DropMark::Box)
@@ -1262,16 +1352,14 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec<u64>, depth:
if !open {
return;
}
for folder in ["bodies", "sketches", "construction"] {
occurrence_folder_rows(app, ui, placement.component, &path, folder, depth + 1, acts);
for (slot, folder) in placement.folders.iter_mut().zip(["bodies", "sketches", "construction"]) {
let data = slot.get_or_insert_with(|| cache.folder(app, placement.component, Some(path), folder));
folder_rows_at(app, ui, placement.component, Some(path), folder, depth + 1, acts, data);
}
features::rows(app, ui, placement.component, &path, depth + 1, acts);
let children = model_children(app, placement.component);
features::rows(app, ui, placement.component, path, depth + 1, acts);
if path.len() < 64 {
for occurrence in children {
let mut child = path.clone();
child.push(occurrence);
occurrence_rows(app, ui, child, depth + 1, acts);
for child in &placement.children {
occurrence_rows(app, ui, child, depth + 1, acts, cache);
}
}
}
@@ -1712,6 +1800,7 @@ fn pick_from_browser(app: &mut SolveApp, x: Sel) {
let hit = match &x {
Sel::Plane { name } => crate::viewport::Hit::Plane { name: name.clone(), point: solidcraft_engine::geom::Vec3::ZERO },
Sel::Axis { name } => crate::viewport::Hit::Axis { name: name.clone() },
Sel::ConstructionPoint { name } => crate::viewport::Hit::ConstructionPoint { name: name.clone() },
// The origin point goes only into a dialog input that takes it.
Sel::Vertex { body, point } if body.is_empty() && app.dialog.is_some() => crate::viewport::Hit::Vertex { body: String::new(), point: *point },
_ => return,
@@ -1728,10 +1817,13 @@ fn pick_from_browser(app: &mut SolveApp, x: Sel) {
/// One item of a component folder.
struct Entry {
/// The highlight lookup key, owned once with the cached row.
thing: Thing,
/// The browser key (body name, or the feature id of a sketch or plane).
key: String,
/// The key in [`TreeState`] row order (`b:`, `s:`, `p:`).
row_key: String,
patterns: Option<String>,
label: String,
icon: &'static str,
visible: bool,
@@ -1744,6 +1836,94 @@ struct Entry {
bodies: Vec<String>,
}
impl Entry {
fn body(app: &SolveApp, b: &solidcraft_engine::doc::ModelBody, world: String) -> Self {
Self {
thing: Thing::Body(world.clone()),
key: b.name.clone(),
row_key: format!("b:{world}"),
patterns: None,
label: b.name.clone(),
// An open shell is a surface body, not a solid.
icon: if b.is_surface() { "surface" } else { "body" },
visible: !app.session.visibility.hidden_bodies.contains(&world),
selected: app.session.selection.iter().any(|s| matches!(s, Sel::Body { name } if name == &world)),
color: None,
locked: app.session.visibility.locked_bodies.contains(&world),
sel: Some(Sel::Body { name: world.clone() }),
target: Some(Target::Body { name: world.clone() }),
bodies: vec![world],
}
}
fn sketch(app: &SolveApp, f: &solidcraft_engine::doc::Feature) -> Self {
let t = Tokens::get();
let active = app.session.active_sketch == Some(f.id);
let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some())
&& matches!(app.session.doc.sketch_face_attachment(f.id, &app.session.model), solidcraft_engine::doc::FaceAttachmentStatus::Lost { .. });
Self {
key: f.id.to_string(),
row_key: format!("s:{}", f.id),
thing: Thing::Sketch(f.id),
patterns: crate::sketch_pattern_tree::has_patterns(app, f.id).then(|| format!("s:{}/patterns", f.id)),
label: if lost {
format!("{} (attachment lost)", f.name)
} else if active {
format!("{} (editing)", f.name)
} else {
f.name.clone()
},
icon: "sketch",
visible: sketch_visible(app, f.id),
selected: active || app.session.selection.iter().any(|s| matches!(s, Sel::Feature { id } if *id == f.id)),
color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) },
locked: false,
sel: Some(Sel::Feature { id: f.id }),
target: Some(Target::Sketch { id: f.id }),
bodies: Vec::new(),
}
}
fn plane(app: &SolveApp, f: &solidcraft_engine::doc::Feature) -> Self {
let sel = construction_sel(f);
let thing = match &sel {
Sel::Axis { name } => Thing::Axis(name.clone()),
Sel::ConstructionPoint { name } => Thing::Point(name.clone()),
_ => Thing::Plane(f.name.clone()),
};
Self {
key: f.id.to_string(),
row_key: format!("p:{}", f.id),
thing,
patterns: None,
label: f.name.clone(),
icon: crate::timeline::icon_of(&f.kind),
visible: !app.session.visibility.hidden_origin.contains(&f.name),
selected: app.session.selection.contains(&sel),
color: None,
locked: false,
sel: Some(sel),
target: Some(Target::Plane { id: f.id }),
bodies: Vec::new(),
}
}
fn is_visible(&self, app: &SolveApp) -> bool {
match &self.sel {
Some(Sel::Body { name }) => !app.session.visibility.hidden_bodies.contains(name),
Some(Sel::Feature { id }) => sketch_visible(app, *id),
Some(Sel::Plane { name }) => !app.session.visibility.hidden_origin.contains(name),
_ => self.visible,
}
}
fn is_selected(&self, app: &SolveApp) -> bool {
self.sel
.as_ref()
.is_some_and(|s| app.session.selection.contains(s) || matches!(s, Sel::Feature { id } if app.session.active_sketch == Some(*id)))
}
}
/// What is being dragged in the tree: items of one component folder.
#[derive(Clone, Debug)]
struct Drag {
@@ -1754,86 +1934,42 @@ struct Drag {
/// The items of a component folder, in model order.
fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
let t = Tokens::get();
let doc = &app.session.doc;
let sel = &app.session.selection;
let state = app.session.model.state();
match folder {
"bodies" => model_body_indices(app, comp)
.into_iter()
.filter_map(|i| state.bodies.get(i))
.filter(|b| !app.session.body_removed_later(&b.name))
.map(|b| Entry {
key: b.name.clone(),
row_key: format!("b:{}", b.name),
label: b.name.clone(),
icon: "body",
visible: !app.session.visibility.hidden_bodies.contains(&b.name),
selected: sel.iter().any(|x| matches!(x, Sel::Body { name } if *name == b.name)),
color: None,
locked: app.session.visibility.locked_bodies.contains(&b.name),
sel: Some(Sel::Body { name: b.name.clone() }),
target: Some(Target::Body { name: b.name.clone() }),
bodies: vec![b.name.clone()],
})
.collect(),
"sketches" => model_feature_indices(app, comp, true)
.into_iter()
.filter_map(|i| doc.features.get(i))
.map(|f| {
let active = app.session.active_sketch == Some(f.id);
let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some())
&& matches!(doc.sketch_face_attachment(f.id, &app.session.model), solidcraft_engine::doc::FaceAttachmentStatus::Lost { .. });
Entry {
key: f.id.to_string(),
row_key: format!("s:{}", f.id),
label: if lost {
format!("{} (attachment lost)", f.name)
} else if active {
format!("{} (editing)", f.name)
} else {
f.name.clone()
},
icon: "sketch",
visible: sketch_visible(app, f.id),
selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)),
color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) },
locked: false,
sel: Some(Sel::Feature { id: f.id }),
target: Some(Target::Sketch { id: f.id }),
bodies: Vec::new(),
}
})
.collect(),
_ => model_feature_indices(app, comp, false)
.into_iter()
.filter_map(|i| doc.features.get(i))
.map(|f| Entry {
key: f.id.to_string(),
row_key: format!("p:{}", f.id),
label: f.name.clone(),
icon: crate::timeline::icon_of(&f.kind),
visible: !app.session.visibility.hidden_origin.contains(&f.name),
selected: sel.iter().any(|x| matches!(x, Sel::Plane { name } if *name == f.name)),
color: None,
locked: false,
sel: Some(Sel::Plane { name: f.name.clone() }),
target: Some(Target::Plane { id: f.id }),
bodies: Vec::new(),
})
.map(|b| Entry::body(app, b, b.name.clone()))
.collect(),
"sketches" => model_feature_indices(app, comp, true).into_iter().filter_map(|i| doc.features.get(i)).map(|f| Entry::sketch(app, f)).collect(),
_ => model_feature_indices(app, comp, false).into_iter().filter_map(|i| doc.features.get(i)).map(|f| Entry::plane(app, f)).collect(),
}
}
fn construction_sel(f: &solidcraft_engine::doc::Feature) -> Sel {
let name = f.name.clone();
match f.kind {
FeatureKind::ConstructionPoint { .. } => Sel::ConstructionPoint { name },
FeatureKind::ConstructionAxis { .. } => Sel::Axis { name },
_ => Sel::Plane { name },
}
}
/// A folder's groups (with their items) and the items in no group, in browser order.
fn arrange(app: &SolveApp, comp: u64, folder: &str, all: Vec<Entry>) -> (Vec<(solidcraft_engine::doc::BrowserGroup, Vec<Entry>)>, Vec<Entry>) {
let doc = &app.session.doc;
let groups: Vec<solidcraft_engine::doc::BrowserGroup> =
doc.browser_groups.iter().filter(|g| g.component == comp && g.folder == folder).cloned().collect();
fn arrange<'a>(
doc: &'a solidcraft_engine::doc::Document,
comp: u64,
folder: &str,
all: Vec<Entry>,
) -> (Vec<(&'a solidcraft_engine::doc::BrowserGroup, Vec<Entry>)>, Vec<Entry>) {
let mut rest: Vec<Option<Entry>> = all.into_iter().map(Some).collect();
let mut take = |key: &str| rest.iter_mut().find(|e| e.as_ref().is_some_and(|e| e.key == key)).and_then(Option::take);
let grouped: Vec<_> = groups
.into_iter()
let grouped: Vec<_> = doc
.browser_groups
.iter()
.filter(|g| g.component == comp && g.folder == folder)
.map(|g| {
let items = g.items.iter().filter_map(|k| take(k)).collect();
(g, items)
@@ -1841,7 +1977,11 @@ fn arrange(app: &SolveApp, comp: u64, folder: &str, all: Vec<Entry>) -> (Vec<(so
.collect();
let mut loose: Vec<Entry> = rest.into_iter().flatten().collect();
if let Some(order) = doc.browser_order.get(&format!("{comp}/{folder}")) {
let pos = |k: &str| order.iter().position(|x| x == k).unwrap_or(usize::MAX);
let mut positions = std::collections::HashMap::with_capacity(order.len());
for (i, key) in order.iter().enumerate() {
positions.entry(key.as_str()).or_insert(i);
}
let pos = |k: &str| positions.get(k).copied().unwrap_or(usize::MAX);
loose.sort_by_key(|e| pos(&e.key));
}
(grouped, loose)
@@ -1886,64 +2026,42 @@ fn folder_static(folder: &str) -> &'static str {
/// A component's folder: its row, its groups and its items. Rows are drag sources; items,
/// groups and the folder row are drop targets (reorder, into a group, out of groups).
fn folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, folder: &str, depth: usize, acts: &mut Actions) {
folder_rows_at(app, ui, comp, None, folder, depth, acts);
}
fn occurrence_folder_rows(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: &[u64], folder: &str, depth: usize, acts: &mut Actions) {
folder_rows_at(app, ui, comp, Some(path), folder, depth, acts);
}
fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option<&[u64]>, folder: &str, depth: usize, acts: &mut Actions) {
let t = Tokens::get();
let mut all = entries(app, comp, folder);
let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}"));
if let Some(path) = path {
let Some(placement) = model_placement(app, path) else { return };
// Each row's placed body by its definition's name (a search per row was quadratic in a
// component's bodies, every frame).
let mut placed: std::collections::HashMap<&str, &_> = std::collections::HashMap::with_capacity(placement.bodies.len());
for b in &placement.bodies {
placed.entry(b.source.as_str()).or_insert(b);
}
for e in &mut all {
if folder == "bodies" {
let Some(body) = placed.get(e.key.as_str()) else { continue };
e.sel = Some(Sel::Body { name: body.world.clone() });
e.target = Some(Target::Body { name: body.world.clone() });
e.bodies = vec![body.world.clone()];
e.visible = !app.session.visibility.hidden_bodies.contains(&body.world);
e.selected = app.session.selection.contains(&Sel::Body { name: body.world.clone() });
e.row_key = format!("b:{}", body.world);
} else {
e.row_key = format!("{prefix}/{}", e.row_key);
}
}
}
if all.is_empty() {
#[allow(clippy::too_many_arguments)]
fn folder_rows_at(
app: &mut SolveApp,
ui: &mut egui::Ui,
comp: u64,
path: Option<&[u64]>,
folder: &str,
depth: usize,
acts: &mut Actions,
data: &cache::Folder,
) {
if data.key.is_empty() {
return;
}
let fstatic = folder_static(folder);
let key = format!("{prefix}/{folder}");
if let Some(r) = app.tree.reveal.clone()
let key = &data.key;
let prefix = key.rsplit_once('/').map_or(key.as_str(), |(prefix, _)| prefix);
if let Some(r) = app.tree.reveal.as_deref()
&& folder == "bodies"
&& let Some(e) = all.iter().find(|e| e.row_key == format!("b:{r}"))
&& let Some(e) = data.entries().find(|e| e.row_key.strip_prefix("b:") == Some(r))
{
app.tree.collapsed.remove(&key);
app.tree.collapsed.remove(key);
app.tree.collapsed.remove(&format!("c{comp}"));
for g in app.session.doc.browser_groups.iter().filter(|g| g.items.contains(&e.key)) {
app.tree.collapsed.remove(&format!("g{}", g.id));
}
}
let open = is_open(app, &key, true);
let open = is_open(app, key, true);
// The folder's eye is its own: hiding it hides its items without changing theirs, and its
// items never change it. It and everything in it fade while it or a placement above is
// hidden, whatever their own eyes say.
let shown = !app.session.folder_hidden(&key);
let shown = !app.session.folder_hidden(key);
let folder_faded = !shown || path.is_some_and(|p| !app.session.occurrence_visible(p));
let r = draw_row(
ui,
ui.id().with(&key),
ui.id().with(key),
&Row {
depth,
fold: Some(open),
@@ -1951,21 +2069,21 @@ fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option
icon: "folder",
label: folder_label(folder),
dim: folder_faded,
mark: acts.lit.branch(folder, &all),
mark: acts.lit.branch(app, data.entries()),
..Default::default()
},
);
if r.fold || r.clicked {
toggle(app, &key, true);
toggle(app, key, true);
}
if r.eye {
toggle_folder(app, &key);
toggle_folder(app, key);
}
if r.hovered {
acts.hover_bodies = all.iter().flat_map(|e| e.bodies.clone()).collect();
acts.hover(data.entries().flat_map(|e| &e.bodies));
}
if let Some(p) = r.secondary {
acts.menu = Some((p, Target::Folder { component: comp, folder: fstatic.to_string(), scope: prefix.clone() }));
acts.menu = Some((p, Target::Folder { component: comp, folder: fstatic.to_string(), scope: prefix.to_owned() }));
}
// Dropping on the folder row takes items out of their groups, to the end. Bodies or sketches
// from another component's folder move into this component, as a drop on its row does
@@ -1982,56 +2100,53 @@ fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option
if !open {
return;
}
let (groups, loose) = arrange(app, comp, folder, all);
let loose_keys: Vec<String> = loose.iter().map(|e| e.key.clone()).collect();
for (g, items) in &groups {
let gkey = format!("{prefix}/g{}", g.id);
let gopen = is_open(app, &gkey, true);
let gshown = !app.session.folder_hidden(&gkey);
let loose_keys = &data.loose_keys;
for group in &data.groups {
let (id, items, gkey) = (group.id, &group.items, &group.key);
let gopen = is_open(app, gkey, true);
let gshown = !app.session.folder_hidden(gkey);
let group_faded = folder_faded || !gshown;
let label = format!("{} ({})", g.name, items.len());
let r = draw_row(
ui,
ui.id().with(&gkey),
ui.id().with(gkey),
&Row {
depth: depth + 1,
fold: Some(gopen),
eye: Some(gshown),
icon: "folder",
label: &label,
label: &group.label,
name: true,
dim: group_faded,
mark: acts.lit.branch(folder, items),
mark: acts.lit.branch(app, items),
..Default::default()
},
);
if r.fold || r.clicked {
toggle(app, &gkey, true);
toggle(app, gkey, true);
}
if r.eye {
toggle_folder(app, &gkey);
toggle_folder(app, gkey);
}
if r.hovered {
acts.hover_bodies = items.iter().flat_map(|e| e.bodies.clone()).collect();
acts.hover(items.iter().flat_map(|e| &e.bodies));
}
if let Some(p) = r.secondary {
acts.menu = Some((p, Target::Group { id: g.id, scope: prefix.clone() }));
acts.menu = Some((p, Target::Group { id, scope: prefix.to_owned() }));
}
if let Some(resp) = &r.resp
&& let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == fstatic, DropMark::Box)
{
acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: Some(g.id), before: None });
acts.drop = Some(DropAction::Move { comp, folder: fstatic, keys: d.keys.clone(), group: Some(id), before: None });
}
if gopen {
for e in items {
entry_row(app, ui, comp, path, fstatic, e, depth + 2, Some(g.id), &loose_keys, group_faded, acts);
entry_row(app, ui, comp, path, fstatic, e, depth + 2, Some(id), loose_keys, group_faded, acts);
}
}
}
for e in &loose {
entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, &loose_keys, folder_faded, acts);
for e in &data.loose {
entry_row(app, ui, comp, path, fstatic, e, depth + 1, None, loose_keys, folder_faded, acts);
}
let _ = t;
}
/// The body's name edited in its row (started by a double-click): Enter or leaving the box keeps
@@ -2086,33 +2201,43 @@ fn entry_row(
parent_hidden: bool,
acts: &mut Actions,
) {
app.tree.order.push(e.row_key.clone());
let pattern_sketch =
(folder == "sketches").then(|| e.key.parse::<u64>().ok()).flatten().filter(|id| crate::sketch_pattern_tree::has_patterns(app, *id));
let pattern_key = format!("{}/patterns", e.row_key);
let patterns_open = is_open(app, &pattern_key, true);
let badges: &[&str] = if e.locked { &["lock"] } else { &[] };
let (selected, mark) = lit_row(acts.lit.entry(folder, e));
app.tree.remember(&e.row_key);
let pattern_key = e.patterns.as_deref();
let patterns_open = pattern_key.is_some_and(|key| is_open(app, key, true));
let rect = row_rect(ui);
if !ui.is_rect_visible(rect) && !patterns_open && !(folder == "bodies" && app.tree.reveal.as_deref() == Some(e.key.as_str())) {
ui.allocate_space(rect.size());
return;
}
let (selected, mark) = lit_row(acts.lit.entry(app, e));
let visible = e.is_visible(app);
let locked = match &e.sel {
Some(Sel::Body { name }) => app.session.visibility.locked_bodies.contains(name),
_ => e.locked,
};
let badges: &[&str] = if locked { &["lock"] } else { &[] };
let r = draw_row(
ui,
ui.id().with(("item", &e.row_key)),
&Row {
depth,
fold: pattern_sketch.map(|_| patterns_open),
eye: Some(e.visible),
fold: pattern_key.map(|_| patterns_open),
eye: Some(visible),
icon: e.icon,
label: &e.label,
name: true,
selected,
dim: !e.visible || parent_hidden,
dim: !visible || parent_hidden,
color: e.color,
badges,
mark,
..Default::default()
},
);
if r.fold {
toggle(app, &pattern_key, true);
if r.fold
&& let Some(key) = pattern_key
{
toggle(app, key, true);
}
if folder == "bodies"
&& app.tree.reveal.as_ref().is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b.as_str()))
@@ -2122,7 +2247,7 @@ fn entry_row(
app.tree.reveal = None;
}
if r.eye {
set_visible(app, folder, &[e], !e.visible);
set_visible(app, folder, &[e], !visible);
}
if r.double && folder == "sketches" {
if let Ok(id) = e.key.parse() {
@@ -2157,25 +2282,23 @@ fn entry_row(
inline_rename(app, ui, rect, e, depth);
}
if r.hovered && !e.bodies.is_empty() {
acts.hover_bodies = e.bodies.clone();
acts.hover(e.bodies.iter());
}
if let Some(p) = r.secondary
&& let Some(t) = e.target.clone()
{
if !e.selected
&& let Some(s) = &e.sel
{
if !selected && let Some(s) = &e.sel {
let _ = app.run("select.set", json!({ "items": [s] }));
}
acts.menu = Some((p, t));
}
if let Some(sketch) = pattern_sketch.filter(|_| patterns_open) {
crate::sketch_pattern_tree::rows(app, ui, sketch, depth + 1, "browser", path.map(|path| (comp, path)), !e.visible || parent_hidden);
if patterns_open && let Some(Sel::Feature { id }) = &e.sel {
crate::sketch_pattern_tree::rows(app, ui, *id, depth + 1, "browser", path.map(|path| (comp, path)), !visible || parent_hidden);
}
let Some(resp) = &r.resp else { return };
// Drag: the selected items of this folder when this one is selected, else just this one.
if resp.drag_started() && !r.on_control {
let keys: Vec<String> = if e.selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
let keys: Vec<String> = if selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] };
resp.dnd_set_drag_payload(Drag { comp, folder, keys });
}
if let Some(d) = drop_target(ui, resp, |d: &Drag| d.comp == comp && d.folder == folder && !d.keys.contains(&e.key), DropMark::Above) {
+1 -1
View File
@@ -75,7 +75,7 @@ pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, component: u64, path:
let Some(feature) = app.session.doc.features.get(index) else { continue };
let (id, label, icon) = (feature.id, feature.name.clone(), crate::timeline::icon_of(&feature.kind));
let selected = app.session.selection.contains(&Sel::Feature { id });
app.tree.order.push(format!("f:{id}"));
app.tree.remember(&format!("f:{id}"));
let r = draw_row(ui, ui.id().with(("browser_feature", id)), &Row { depth: depth + 1, icon, label: &label, selected, ..Default::default() });
if let Some(rect) = r.rect {
crate::scenario::publish_handle(&format!("row:browser-feature:{scope}:{id}"), rect.center());
+205
View File
@@ -0,0 +1,205 @@
//! Stable browser data, indexed once for an evaluated document and borrowed on redraw.
use super::*;
use solidcraft_engine::doc::{Document, ModelState};
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Weak};
#[derive(Default)]
struct Component {
definition: Option<usize>,
first: Option<Vec<u64>>,
}
#[derive(Default)]
pub(super) struct Node {
pub component: u64,
pub occurrence: u64,
pub name: String,
pub label: String,
pub key: String,
pub tooltip: String,
pub grounded: bool,
pub first: bool,
pub children: Vec<Vec<u64>>,
pub hover_bodies: Option<Vec<String>>,
pub folders: [Option<Folder>; 3],
}
pub(super) struct Group {
pub id: u64,
pub key: String,
pub label: String,
pub items: Vec<Entry>,
}
#[derive(Default)]
pub(super) struct Folder {
pub key: String,
pub groups: Vec<Group>,
pub loose: Vec<Entry>,
pub loose_keys: Vec<String>,
}
impl Folder {
pub fn entries(&self) -> impl Iterator<Item = &Entry> {
self.groups.iter().flat_map(|g| &g.items).chain(&self.loose)
}
}
#[derive(Default)]
pub(super) struct Cache {
doc: Weak<Document>,
model: Weak<ModelState>,
sketch: Option<u64>,
colors: [Color32; 2],
components: BTreeMap<u64, Component>,
ids: BTreeMap<u64, usize>,
scheduled: usize,
pub root: Node,
pub root_bodies: Vec<String>,
pub occurrences: HashMap<Vec<u64>, Node>,
}
impl Cache {
pub fn prepare(&mut self, app: &SolveApp) {
let state = app.session.model.state();
let t = Tokens::get();
let colors = [t.error, t.sketch_accent];
if std::ptr::eq(self.doc.as_ptr(), Arc::as_ptr(&app.session.doc))
&& std::ptr::eq(self.model.as_ptr(), Arc::as_ptr(&state))
&& self.sketch == app.session.active_sketch
&& self.colors == colors
{
return;
}
self.components.clear();
self.ids.clear();
self.occurrences.clear();
for (i, component) in app.session.doc.components.iter().enumerate() {
self.components.entry(component.id).or_default().definition = Some(i);
}
for (i, occurrence) in app.session.doc.occurrences.iter().enumerate() {
self.ids.insert(occurrence.id, i);
}
self.root = Node {
name: component_name(app, 0),
key: "c0".into(),
children: self.children(app, 0, &[], solidcraft_engine::doc::MAX_PLACEMENTS),
..Default::default()
};
self.scheduled = self.root.children.len();
self.root_bodies = crate::context_menu::component_bodies(app, 0);
// Weak snapshot identities catch replacement and reevaluation without retaining a
// closed design's geometry while the browser is collapsed.
self.doc = Arc::downgrade(&app.session.doc);
self.model = Arc::downgrade(&state);
self.sketch = app.session.active_sketch;
self.colors = colors;
}
fn children(&self, app: &SolveApp, component: u64, path: &[u64], remaining: usize) -> Vec<Vec<u64>> {
let Some(children) = app.tree.model.as_ref().and_then(|model| model.children.get(&component)) else { return Vec::new() };
if path.len() >= 64 {
return Vec::new();
}
children
.iter()
.filter(|id| !path.contains(id))
.take(remaining)
.map(|id| {
let mut child = Vec::with_capacity(path.len() + 1);
child.extend_from_slice(path);
child.push(*id);
child
})
.collect()
}
pub fn take_occurrence(&mut self, app: &mut SolveApp, path: &[u64]) -> Option<(Vec<u64>, Node)> {
if let Some(cached) = self.occurrences.remove_entry(path) {
return Some(cached);
}
if path.is_empty() || path.len() > 64 {
return None;
}
let doc = self.doc.upgrade()?;
let component = model_placement(app, path)?.component;
let occurrence = doc.occurrences.get(*self.ids.get(path.last()?)?)?;
let index = self.components.get_mut(&component)?;
let definition = doc.components.get(index.definition?)?;
let first = index.first.get_or_insert_with(|| doc.first_path(component)) == path;
let mut tooltip = format!("Component: {}\nPlacement: ", definition.name);
for (i, id) in path.iter().enumerate() {
if i != 0 {
tooltip.push_str(" / ");
}
tooltip.push_str(&doc.occurrences.get(*self.ids.get(id)?)?.name);
}
tooltip.push_str("\nGeometry edits apply to every instance of this component.");
let children = self.children(app, component, path, solidcraft_engine::doc::MAX_PLACEMENTS.saturating_sub(self.scheduled));
self.scheduled += children.len();
Some((
path.to_vec(),
Node {
component,
occurrence: occurrence.id,
name: occurrence.name.clone(),
label: crate::dialogs_part_size::browser_label(app, component, &occurrence.name),
key: occurrence_key(path),
tooltip,
grounded: occurrence.grounded,
first,
children,
hover_bodies: None,
folders: Default::default(),
},
))
}
pub fn folder(&self, app: &SolveApp, component: u64, path: Option<&[u64]>, folder: &str) -> Folder {
let Some(model) = &app.tree.model else { return Folder::default() };
let Some(state) = self.model.upgrade() else { return Folder::default() };
let Some(doc) = self.doc.upgrade() else { return Folder::default() };
let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{component}"));
let indices = match folder {
"bodies" => &model.bodies,
"sketches" => &model.sketches,
_ => &model.planes,
};
let Some(indices) = indices.get(&component) else { return Folder::default() };
let mut all = Vec::with_capacity(indices.len());
for i in indices {
let entry = match folder {
"bodies" => state.bodies.get(*i).filter(|b| !app.session.body_removed_later(&b.name)).map(|b| {
let world = path.map_or_else(|| b.name.clone(), |p| doc.world_body_name(&state, &b.name, component, p));
Entry::body(app, b, world)
}),
"sketches" => doc.features.get(*i).map(|f| Entry::sketch(app, f)),
_ => doc.features.get(*i).map(|f| Entry::plane(app, f)),
};
if let Some(mut entry) = entry {
if path.is_some() && folder != "bodies" {
entry.row_key = format!("{prefix}/{}", entry.row_key);
if let Some(key) = &mut entry.patterns {
*key = format!("{prefix}/{key}");
}
}
all.push(entry);
}
}
if all.is_empty() {
return Folder::default();
}
let (groups, loose) = arrange(&doc, component, folder, all);
let loose_keys = loose.iter().map(|e| e.key.clone()).collect();
let groups = groups
.into_iter()
.map(|(group, items)| {
let key = format!("{prefix}/g{}", group.id);
let label = format!("{} ({})", group.name, items.len());
Group { id: group.id, key, label, items }
})
.collect();
Folder { key: format!("{prefix}/{folder}"), groups, loose, loose_keys }
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ impl Model {
}
let index = match feature.kind {
FeatureKind::Sketch { .. } => &mut sketches,
FeatureKind::ConstructionPlane { .. } => &mut planes,
FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. } => &mut planes,
_ => continue,
};
index.entry(feature.component).or_default().push(i);
+54
View File
@@ -141,3 +141,57 @@ fn hovering_a_collapsed_nested_placement_walks_its_subtree_once() {
h.call("ui.move", json!({"x":at.x,"y":at.y + 1.0}));
assert_eq!(h.app.viewport.hover_bodies.len(), 7);
}
/// An open curve turned round is a surface body and the Bodies folder shows it as one; a closed
/// profile turned all the way round, and a box, are solids.
#[test]
fn bodies_folder_tells_surfaces_from_solids() {
let mut h = crate::scenario::Harness::new();
let mut run = |id: &str, p: serde_json::Value| {
h.app.run(id, p).unwrap();
};
run("sketch.create", json!({"plane":"XY", "name":"S"}));
run("sketch.line", json!({"points":[[3, 0], [3, 4]], "ids":["side"]}));
run("sketch.rectangle.two_point", json!({"p0":[5, 0], "p1":[7, 3]}));
run("sketch.finish", json!({}));
run("surface.revolve", json!({"sketch":"S", "curves":["side"], "axis":"y", "body_name":"Tube"}));
run("surface.revolve", json!({"sketch":"S", "profiles":[0], "axis":"y", "body_name":"Ring"}));
run("solid.box", json!({"length":2, "width":2, "height":2, "corner":[20, 0, 0]}));
super::prepare_model(&mut h.app);
let icons: Vec<(String, &str)> = super::entries(&h.app, 0, "bodies").into_iter().map(|e| (e.key, e.icon)).collect();
assert_eq!(icons.len(), 3, "{icons:?}");
assert_eq!(icons[..2], [("Tube".to_string(), "surface"), ("Ring".to_string(), "body")]);
assert_eq!(icons[2].1, "body", "the box is a solid");
}
#[test]
fn construction_rows_include_points_and_axes() {
let mut h = crate::scenario::Harness::new();
h.app.run("construct.point.vertex", json!({"point":{"point":[20,15,10]},"name":"Point1"})).unwrap();
h.app.run("construct.axis.two_points", json!({"a":{"point":[0,0,0]},"b":{"point":[0,0,30]},"name":"Axis1"})).unwrap();
for cached in [false, true] {
if cached {
super::prepare_model(&mut h.app);
}
let rows = super::entries(&h.app, 0, "construction");
assert_eq!(rows.iter().map(|r| r.label.as_str()).collect::<Vec<_>>(), ["Point1", "Axis1"]);
for row in &rows {
assert!(row.visible && row.sel.is_some() && row.target.is_some());
}
}
let rows = super::entries(&h.app, 0, "construction");
for row in &rows {
super::pick_from_browser(&mut h.app, row.sel.clone().unwrap());
assert!(super::entries(&h.app, 0, "construction").iter().find(|r| r.label == row.label).unwrap().selected);
let items = crate::context_menu::items(&h.app, row.target.as_ref().unwrap());
for id in ["ui.rename", "ui.delete", "ui.editFeature", "ui.originPart"] {
assert!(items.iter().any(|i| i.id == id), "{} lacks {id}", row.label);
}
assert!(!items.iter().any(|i| i.id == "sketch.create"));
}
h.app.session.visibility.hidden_origin.push("Point1".into());
assert!(!super::entries(&h.app, 0, "construction")[0].visible);
h.app.run("selection.delete", json!({"items": rows.iter().map(|r| r.sel.clone().unwrap()).collect::<Vec<_>>()})).unwrap();
super::prepare_model(&mut h.app);
assert!(super::entries(&h.app, 0, "construction").is_empty());
}
+61 -15
View File
@@ -32,7 +32,7 @@ pub enum Target {
Sketch {
id: u64,
},
/// A construction plane (its feature id).
/// Construction geometry (its feature id).
Plane {
id: u64,
},
@@ -527,6 +527,9 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "solid.shell", "Shell"));
v.push(act("ui.editFeature", "Edit Feature", "edit").with(json!({ "feature": feature })).on(feature.is_some()));
v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some()));
let planar =
sel.iter().any(|s| matches!(s, Sel::Face { body, index, .. } if crate::dialogs::planar_face(&app.session, body, *index).is_some()));
v.push(act("ui.lookAt", "Look At", "lookat").on(planar));
v.push(Item::sep());
v.push(act("ui.appearance", "Appearance", "appearance").key("A"));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] })));
@@ -561,12 +564,16 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(cmd(app, "solid.revolve", "Revolve"));
} else {
// Empty space, or a plane: both lead with Create Sketch (#3032). On a plane it sketches
// there; on empty space it is the toolbar's, which asks for the plane or face.
// there, with Look At beside it; on empty space it is the toolbar's, which asks for the
// plane or face.
let sketch = cmd(app, "sketch.create", "Create Sketch");
v.push(match &app.menu.plane {
Some(plane) => sketch.with(json!({ "plane": plane })),
None => sketch,
});
match &app.menu.plane {
Some(plane) => {
v.push(sketch.with(json!({ "plane": plane })));
v.push(act("ui.lookAt", "Look At", "lookat").with(json!({ "plane": plane })));
}
None => v.push(sketch),
}
v.push(Item::sep());
v.push(act("ui.nav", "Pan", "pan").with(json!({ "mode": "pan" })));
v.push(act("ui.nav", "Zoom", "zoom").with(json!({ "mode": "zoom" })));
@@ -668,22 +675,26 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec<Item> {
]
}
/// A construction plane's row in the browser: sketch on it, edit, delete, rename, show or hide.
/// Construction geometry's row: edit, delete, rename, show or hide; sketch on planes.
fn plane_items(app: &SolveApp, id: u64) -> Vec<Item> {
let Some(f) = app.session.doc.feature(id) else { return Vec::new() };
let visible = !app.session.visibility.hidden_origin.contains(&f.name);
vec![
cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": f.name })),
Item::sep(),
act("ui.editFeature", "Edit Feature", "plane").with(json!({ "feature": id })),
Item::sep(),
let mut items = Vec::new();
if matches!(f.kind, solidcraft_engine::doc::FeatureKind::ConstructionPlane { .. }) {
items.push(cmd(app, "sketch.create", "Create Sketch").with(json!({ "plane": f.name })));
items.push(Item::sep());
}
items.push(act("ui.editFeature", "Edit Feature", crate::timeline::icon_of(&f.kind)).with(json!({ "feature": id })));
items.push(Item::sep());
items.extend([
act("ui.delete", "Delete", "delete").key("Del").with(json!({ "items": [{"type": "feature", "id": id}] })),
act("ui.rename", "Rename", "rename").with(json!({ "feature": id })),
Item::sep(),
act("ui.originPart", "Show/Hide", "eye").key("V").with(json!({ "keys": [f.name], "show": !visible })),
Item::sep(),
act("ui.findTimeline", "Find in Timeline", "rollback").with(json!({ "feature": id })),
]
]);
items
}
/// An origin plane's row in the browser: sketch on it, show or hide.
@@ -806,6 +817,9 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
let grounded = occ.is_some_and(|o| o.grounded);
let oid = occ.map(|o| o.id).unwrap_or(0);
v.push(act("ui.activate", "Activate", "component").with(json!({ "component": id })).on(!active));
if doc.links.contains_key(&id) {
v.push(act("ui.openLinkedDesign", "Open Linked Design", "open").with(json!({ "component": id })));
}
v.push(cmd(app, "occurrence.ground", if grounded { "Unground" } else { "Ground" }).with(json!({ "occurrence": oid, "grounded": !grounded })));
v.push(
act("ui.moveOccurrence", "Move/Copy", "move")
@@ -838,6 +852,9 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
.with(if id == 0 { json!({ "bodies": names }) } else { json!({"component": id}) })
.on(id != 0 || !bodies.is_empty()),
);
if id != 0 {
v.push(cmd(app, "component.cycle_color", "Cycle Component Color").with(json!({"component": id})));
}
v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id })));
if doc.links.contains_key(&id) {
v.push(act("ui.relinkSource", "Relink Source (All Instances)…", "update").with(json!({"component": id})));
@@ -1257,6 +1274,29 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
let _ = app.run("component.activate", p);
}
}
"ui.openLinkedDesign" => {
let Some(component) = id_of(&p, "component") else { return };
let links = match app.session.execute("component.links", &json!({})) {
Ok(links) => links,
Err(e) => {
app.set_status(e.to_string(), true);
return;
}
};
let Some(link) = links["links"].as_array().and_then(|links| links.iter().find(|link| link["component"] == component)) else {
app.set_status("This component is no longer linked to a design", true);
return;
};
let Some(file) = link["file"].as_str() else {
app.set_status("The linked design has no file path", true);
return;
};
if link["found"] != true {
app.set_status(format!("Linked design not found: {file}"), true);
return;
}
app.open_path_async(file);
}
"ui.moveOccurrence" => {
if let Some(o) = id_of(&p, "occurrence") {
let mut movement = crate::browser::OccMove::new(o);
@@ -1385,6 +1425,12 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
if let Some(ss) = id_of(&p, "sketch").and_then(|id| st.sketch(id)) {
let to = crate::dialogs::onto_sketch(app.cam.looking_from(ss.plane.normal()), ss);
app.animate_to(to);
} else if let Some(plane) = p.get("plane").and_then(Value::as_str).and_then(solidcraft_engine::geom::Plane::named) {
let mut to = app.cam.looking_from(crate::dialogs::facing(app, plane.normal(), true));
to.target = plane.origin;
app.animate_to(to);
} else if p.get("sketch").is_none() {
crate::viewport::look_at_selection(app);
}
}
"ui.nav" => {
@@ -1447,7 +1493,7 @@ pub fn toggle_visibility(app: &mut SolveApp) {
.selection
.iter()
.filter_map(|s| match s {
Sel::Plane { name } | Sel::Axis { name } => Some(name.clone()),
Sel::Plane { name } | Sel::Axis { name } | Sel::ConstructionPoint { name } => Some(name.clone()),
_ => None,
})
.collect();
@@ -1485,7 +1531,7 @@ pub fn rename_selection(app: &mut SolveApp, at: Pos2) -> bool {
Ok(p) => RenameWhat::Component(p.component),
Err(_) => return false,
},
([Sel::Plane { name }], [], []) => match app.session.doc.find_feature(name) {
([Sel::Plane { name } | Sel::Axis { name } | Sel::ConstructionPoint { name }], [], []) => match app.session.doc.find_feature(name) {
Some(f) => RenameWhat::Feature(f.id),
None => return false,
},
+12 -6
View File
@@ -292,7 +292,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides
&app.cam,
crate::viewport::section_plane(app),
solidcraft_engine::view::world_curve_parts,
&crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision),
&crate::viewport::scene_palette(app.viewport_dark(), app.preferences.color_vision),
guides,
app.preferences.cosmetic_threads,
);
@@ -1168,7 +1168,7 @@ mod tests {
}
/// `ui.render` draws in the window's theme (#974): its background is the viewport's, light
/// or dark.
/// or dark, and with the 3D view in the other theme's colours, the view's (#4146).
#[test]
fn model_render_is_in_the_windows_theme() {
if !crate::gpu::test_picture_renderer() {
@@ -1179,9 +1179,15 @@ mod tests {
let mut app = SolveApp::new(s, Services::default());
let dir = std::env::temp_dir().join(format!("solidcraft-render-theme-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for dark in [false, true, false] {
app.ui.dark = dark;
let png = render(&mut app, &dir.join(format!("dark-{dark}.png")));
use crate::theme::ViewportTheme;
for (window, view) in
[(false, ViewportTheme::Theme), (true, ViewportTheme::Theme), (true, ViewportTheme::Light), (false, ViewportTheme::Dark)]
{
app.ui.dark = window;
app.preferences.viewport_theme = view;
let dark = app.viewport_dark();
let theme = format!("{window}-{}", view.id());
let png = render(&mut app, &dir.join(format!("{theme}.png")));
let img = image::load_from_memory(&png).unwrap().to_rgba8();
// The top row is the background's top: its most common colour.
let mut counts = std::collections::HashMap::new();
@@ -1191,7 +1197,7 @@ mod tests {
let top = counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap();
let want = if dark { crate::theme::Tokens::dark() } else { crate::theme::Tokens::light() }.viewport_top;
let near = top.iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2);
assert!(near, "dark {dark}: the render's top row is {top:?}, the window's background {want:?}");
assert!(near, "{theme}: the render's top row is {top:?}, the view's background {want:?}");
}
std::fs::remove_dir_all(&dir).ok();
}
+8
View File
@@ -223,6 +223,14 @@ impl SolveApp {
let (id, params) = self.deferred_queue.remove(0);
let _ = self.run_heavy(&id, params);
}
if self.deferred.is_none()
&& self.deferred_queue.is_empty()
&& let Some((id, sketch)) = self.start_after_roll.take()
&& self.dialog.is_none()
&& self.tool.is_none()
{
self.start_interactive(&id, Some(sketch));
}
}
/// Start evaluating a command ahead. `None` when it should just run: not asked for
+48 -18
View File
@@ -127,10 +127,11 @@ crate::dialog_descriptor::dialog_descriptors! {
margin: String = "2 mm".into() => margin.to_string(),
} => json!({"margin": margin});
Pipe("solid.pipe", "PIPE", [("Path", CURVES, true)],
FeatureKind::Pipe { diameter, wall, .. }) {
FeatureKind::Pipe { diameter, wall, operation, .. }) {
diameter: String = "5 mm".into() => diameter.to_string(),
wall: String = String::new() => wall.as_deref().map(str::to_string).unwrap_or_default(),
} => json!({"diameter": diameter});
operation: usize = 0 => op_index(operation),
} => json!({"diameter": diameter, "operation": OPS.get(*operation).copied().unwrap_or("new")});
Emboss("solid.emboss", "EMBOSS", [("Profiles", PROFILES, true), ("Target Face (optional)", FACES, false)],
FeatureKind::Emboss { depth, deboss, .. }) {
depth: String = "1 mm".into() => depth.to_string(),
@@ -609,6 +610,7 @@ fn fits(a: Accept, s: &Sel) -> bool {
Sel::Body { .. } => a & BODIES != 0,
Sel::Plane { .. } => a & PLANES != 0,
Sel::Axis { .. } => a & AXES != 0,
Sel::ConstructionPoint { .. } => a & selection::CONSTRUCTION_POINTS != 0,
Sel::SketchCurve { .. } => a & (AXES | CURVES | selection::SKETCH_LINES) != 0,
Sel::ExternalSketchCurve { .. } => a & selection::SKETCH_LINES != 0,
Sel::Vertex { .. } => a & selection::VERTICES != 0,
@@ -801,7 +803,7 @@ impl Dialog {
),
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies), rail_fit: 0 }, {
let [rail, rail2] = crate::dialogs_sweep::rail_inputs();
vec![SelInput::new("Profiles", PROFILES, true), rail, rail2]
vec![SelInput::new("Profiles", PROFILES | selection::POINTS, true), rail, rail2]
}),
"solid.sweep" => Dialog::new(Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() }, {
let [rail, rail2] = crate::dialogs_sweep::rail_inputs();
@@ -998,6 +1000,18 @@ impl Dialog {
Dialog::new(Kind::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default() }, vec![])
}
/// An Extrude or Revolve started from inside a sketch takes that sketch's profile, as it would
/// had the sketch stayed open.
pub(crate) fn take_sketch_profile(&mut self, s: &Session, sketch: u64) {
if matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. })
&& let Some(inp) = self.inputs.first_mut()
&& inp.items.is_empty()
{
inp.items = single_profile(s, sketch);
self.advance();
}
}
/// Make the first single input still empty (after the filled ones) active.
fn advance(&mut self) {
if let Some(i) = self.inputs.iter().position(|i| i.items.is_empty()) {
@@ -1440,12 +1454,15 @@ fn fillet_edges(s: &Session, sel: &Sel, chain: bool) -> Vec<Sel> {
/// the last sketch's while no feature builds from it yet. A used profile (a cube's sketch on its
/// bottom face) is only taken when it is picked.
fn default_profiles(s: &Session) -> Vec<Sel> {
let st = s.world_state();
let sid = s.active_sketch.or_else(|| {
s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id).filter(|&id| !profiles_used(s, id))
});
match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) {
Some((id, 1)) => vec![Sel::Profile { sketch: id, index: 0 }],
sid.map(|id| single_profile(s, id)).unwrap_or_default()
}
fn single_profile(s: &Session, sketch: u64) -> Vec<Sel> {
match s.world_state().sketch(sketch).map(|ss| ss.profiles.len()) {
Some(1) => vec![Sel::Profile { sketch, index: 0 }],
_ => Vec::new(),
}
}
@@ -2054,7 +2071,7 @@ fn selection_label(s: &Session, item: &Sel) -> String {
match item {
Sel::SketchCurve { id, .. } => id.clone(),
Sel::SketchPoint { id } => crate::sketch_modify_ui::point_id(s, item).unwrap_or(id).to_string(),
Sel::Plane { name } | Sel::Axis { name } | Sel::Body { name } => name.clone(),
Sel::Plane { name } | Sel::Axis { name } | Sel::ConstructionPoint { name } | Sel::Body { name } => name.clone(),
Sel::Face { body, index, .. } => format!("{body} · face {index}"),
Sel::Edge { body, index, .. } => format!("{body} · edge {index}"),
Sel::Feature { id } => s.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")),
@@ -3693,20 +3710,33 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}
Kind::Loft { operation, rail_fit } => {
need(0, "profiles of two or more sketches")?;
// One section per sketch, in the order the sketches were first picked.
let mut sections: Vec<(u64, Vec<usize>)> = Vec::new();
// One section per sketch, in the order the sketches were first picked; a sketch point
// is a section of its own, an apex the loft closes to (#4830).
let mut sections: Vec<(u64, Vec<usize>, Option<&str>)> = Vec::new();
for x in sels(d, 0) {
if let Sel::Profile { sketch, index } = x {
match sections.iter_mut().find(|(sk, _)| sk == sketch) {
Some((_, v)) => v.push(*index),
None => sections.push((*sketch, vec![*index])),
match x {
Sel::Profile { sketch, index } => match sections.iter_mut().find(|(sk, _, apex)| sk == sketch && apex.is_none()) {
Some((_, v, _)) => v.push(*index),
None => sections.push((*sketch, vec![*index], None)),
},
Sel::SketchPoint { id } => {
if let Some((sk, point)) = id.split_once(':').and_then(|(sk, point)| Some((sk.parse::<u64>().ok()?, point))) {
sections.push((sk, Vec::new(), Some(point)));
}
}
_ => {}
}
}
if sections.len() < 2 {
return Err("pick profiles in two or more sketches".into());
return Err("pick profiles in two or more sketches, or a profile and a sketch point".into());
}
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
let sections: Vec<Value> = sections
.into_iter()
.map(|(sk, v, apex)| match apex {
Some(point) => json!({"sketch": sk, "point": point}),
None => json!({"sketch": sk, "profiles": v}),
})
.collect();
let mut p = json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")});
crate::dialogs_sweep::rail_params(app, *rail_fit, [sels(d, 1), sels(d, 2)], &mut p)?;
("solid.loft", p)
@@ -4466,7 +4496,7 @@ fn emboss_face_sel(s: &Session, component: u64, face: &solidcraft_engine::doc::E
/// Plane At Angle have dialogs of their own.
fn construct_picks(id: &str) -> Option<(&'static str, &'static str, &'static [(&'static str, Accept, &'static str)])> {
const PLANE: Accept = PLANES | PLANAR_FACES;
const POINT: Accept = selection::VERTICES | selection::POINTS;
const POINT: Accept = selection::VERTICES | selection::POINTS | selection::CONSTRUCTION_POINTS;
const LINE: Accept = EDGES | AXES;
const CURVE: Accept = EDGES | CURVES;
Some(match id {
@@ -4520,6 +4550,7 @@ fn construct_ref(app: &SolveApp, sel: &Sel) -> Result<Value, String> {
Sel::Vertex { point, .. } => json!({"vertex": pt(*point)}),
Sel::Plane { name } => json!({"plane": name}),
Sel::Axis { name } => json!({"axis": name}),
Sel::ConstructionPoint { name } => json!({"construct_point": name}),
// Picked across sketches as "<sketch id>:<point id>".
Sel::SketchPoint { id } => match id.split_once(':').and_then(|(sk, p)| Some((sk.parse::<u64>().ok()?, p))) {
Some((sketch, point)) => json!({"sketch": sketch, "point": point}),
@@ -5336,13 +5367,12 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Pipe { path_sketch, path, operation, targets, .. } => {
FeatureKind::Pipe { path_sketch, path, targets, .. } => {
let mut d = start("solid.pipe")?;
d.kind = Kind::simple_restore(&f.kind)?;
if let Some(inp) = d.inputs.first_mut() {
inp.items = path.iter().map(|id| crate::selection::curve_pick(id, path_sketch.0, s.active_sketch)).collect();
}
d.extra.insert("operation".into(), json!(OPS.get(op_index(operation)).copied().unwrap_or("new")));
if !targets.is_empty() {
d.extra.insert("targets".into(), json!(targets));
}

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