Compare commits

...

137 Commits

Author SHA1 Message Date
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 843c548589 Copy, cut and paste sketch geometry (Ctrl+C/X/V, context menu) (#1500)
* Copy, cut and paste sketch geometry (Ctrl+C/X/V, context menu)

Sketch geometry had no copy or paste: Ctrl+C / Ctrl+X / Ctrl+V arrive as
clipboard events that nothing handled, and the sketch context menu had no
Copy, Cut or Paste.

- sketch.copy / sketch.cut / sketch.paste: lines, arcs, circles, ellipses,
  splines, conics, points and text. Constraints and dimensions wholly among
  the copied entities come along (each dimension gets its own parameter
  with the same expression); ones that reach outside, fixes and pattern or
  offset relations are dropped. Paste goes centred at `at`, by
  `translate`, or offset from the original (same sketch) / at the same
  coordinates (another sketch, on its plane). One undo step; the pasted
  geometry is selected.
- Ctrl+C / Ctrl+X / Ctrl+V in a sketch (paste at the cursor), and Cut,
  Copy and Paste in the sketch context menu. Outside a sketch Ctrl+C /
  Ctrl+V copy and paste the selected timeline features. A copy also puts a
  line of text on the system clipboard so the next Ctrl+V is delivered.

Fixes #1480

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

* Keep a pasted text's height and angle expressions, drop its place's

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:55:10 -04:00
rlartot 7934edcad4 Start page: translate its buttons, links, headings and samples (#1479)
Checked locally on main merged with this: cargo check and the ui-egui tests, including the new i18n ones.
2026-10-15 15:38:46 -04:00
bherbruck b56648c055 Dialogs: steady Select chip, and dialogs move by their title (#1417)
Checked locally on main merged with this: cargo check --workspace and the ui-egui tests (the only failures are the local-only TexturesDelta ones that main has too).
2026-10-15 15:33:09 -04:00
phanoob 6b30326e6d Extrude: a cut takes in a hole of its profile that lies over empty space (#1472)
Checked locally on main merged with this: cargo check --workspace, the kernel and doc tests, and all robustness goldens (all_0 to all_3) pass.
2026-10-15 15:33:05 -04:00
phanoob 39f6d68a50 Text cut through a part, or joined in relief, in one exact boolean (#1446)
Checked locally on main merged with this: cargo check --workspace, the kernel and doc tests, and all robustness goldens (all_0 to all_3) pass.
2026-10-15 15:33:01 -04:00
bherbruck 5fbbc13885 Render preview: draw within the pixel budget on 4K/5K screens (#1464) (#1477)
The product preview asked the viewport executor for the full physical
viewport, so a maximised 4K window (~8 MP) was refused against the 4 MP
limit on every frame. The preview now draws at the largest size of the
same aspect the executor admits (RenderLimits::fit, scratch included)
and filters it up into the viewport through the existing offscreen
blit. Viewports within the limits still draw at full size.

PNG setups past the image limits now say what the limits are and the
largest size at that aspect, and the image panel offers to use it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:18:00 -04:00
NOWN-DEV b6806b16cf feat: a sketch text's height, place and angle can be expressions (#1453)
A sketch text's height, place (x and y) and angle can each be an expression over the design's parameters, and the text follows parameter changes, undo and reopening. Closes #1362.

Co-authored-by: NOWN-DEV <NOWN-DEV@users.noreply.github.com>
2026-10-15 14:57:36 -04:00
bherbruck e20cf1858a Flat pattern gets its own pan and zoom through the shared navigation (#1484)
The flat pattern now keeps a 2D view of its own, driven by navigation::handle with orbit off,
so pan, zoom to the cursor and reverse zoom follow the navigation preset. Input over it no
longer reaches the Design camera, and Fit and Home frame the pattern. Closes #1483.
2026-10-15 14:48:24 -04:00
phanoob 6ad391fd75 Chamfer: refuse a result that grows a convex edge's part (#1428)
* Chamfer: refuse a result that grew a convex edge's part (or shrank a concave one's)

A chamfer of a curved edge that runs off a face it skims (robustness seed 57's
ball rim) built a part bigger than before. Checked from the two faces' normals
at the edge; rounds and unclear angles are left to the wedge bound.

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

* Action matrix: the sheet metal tray's relief corners refuse a 0.25 mm chamfer

Each is a 2.5 mm concave edge (the corner of a corner relief, between two
planes at right angles): a chamfer there fills at most 0.25²/2 × 2.5 = 0.08
mm³. What was built took 0.95 mm³ away; the chamfer guard now refuses it.

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

* Robustness goldens: seed 57's wrong-way chamfer is now refused (#1390)

On main with #391, seed 57's chamfer grew the part; this refuses it, so the run goes on
to its later steps and ends ok.

---------

Co-authored-by: Phanoob <phanoob@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-15 14:26:30 -04:00
bherbruck 2d069ca186 In-app agent chat: your own coding agents over ACP, plus OpenAI-compatible endpoints (#1102)
* Agent chat prototype: drive an ACP agent the user already has from a side panel

A desktop-only chat panel (off by default) that starts a coding agent over the
Agent Client Protocol and gives it SolidCraft's MCP server, bridged to the open
window through the token-protected control channel with the default folder
limits. It finds installed agents (Claude Code and Codex adapters, Gemini CLI,
Copilot, Qwen, OpenCode, goose) with version and login state, streams text,
tool calls and plans, shows the agent's permission requests, and when an agent
isn't signed in shows its own login methods without handling any credential.

Design notes and findings: docs/design/in-app-agent-chat.md.

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

* docs: say only what the end-to-end run showed

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

* Agent chat: gate, one undo per message, docking, process groups, discovery polish

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

* Agent chat: Settings → AI, several agents with a switcher, OpenAI-compatible endpoints, permission modes, toolbar button

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

* Agent chat: translations, tool cards, Add agent dialog fixes, test agent saves on its own

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

* Agent chat: third-party licences for the keychain crates; clippy

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

* Agent chat: badge text from the theme tokens

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

* Agent chat: Auto-allow model edits is the default permission mode

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:22:38 -04:00
Mateusz Sych 02fe495d38 Joints: a link ratio between an angle and a distance is per degree (#1433)
A motion link's ratio is read in b's units per a's (mm per degree, degrees per mm) when an angle and a distance are linked; stored values stay per radian, so saved designs are unchanged. Follow-up to #1415.

Co-authored-by: SychPL <SychPL@users.noreply.github.com>
2026-10-15 14:05:24 -04:00
selune 1e984c8e46 render: normalize high precision texture inputs (#1267)
Adds a bounded normalization path for PNG16 and ICC-bearing JPEG texture inputs, producing float RGBA with recorded color and orientation decisions.

Co-authored-by: 0xlillith <0xlillith@users.noreply.github.com>
2026-10-15 13:55:35 -04:00
selune a47e82ba94 ui: review candidate physical material cards without assignment (#1344)
Adds a read-only review window to Validate Physical Material Data: paste a schema-1 card, see its known properties in SI units, unknown ones marked, with stated conditions and source claims. Nothing is assigned or saved.

Co-authored-by: 0xlillith <0xlillith@users.noreply.github.com>
2026-10-15 13:55:30 -04:00
bherbruck be985f136d Render preview uses the shared camera navigation (#1462)
* Render preview uses the shared camera navigation

The Render workspace's product preview had its own hard-coded camera
controls. Camera input now lives in one component, navigation::handle,
which the modelling viewport and the Render preview both call (NavCaps:
orbit, pick). Render gets the navigation preset, Orbit around, zoom to the
cursor, the middle double-click and the axis view or navigation cube.
Flat pages can call it with orbit off. A test rejects pages that read the
wheel or move the camera themselves.

Fixes #1454

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

* Render panel hint says the camera moves as in Design

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:48:05 -04:00
midasdf e8e2ccc8a9 Class-A surfacing 1/3: the continuity core, a G3 free-form blend and patch crown (#1249)
The continuity core (kernel bridge: Hermite control rows reproducing a support surface's position, tangent, curvature and change of curvature, G0 to G3), surface.blend between two edge chains (#764), and a crown for fitted patches. Existing patches and fillets are unchanged by default (crown 0 is the plain patch bit for bit). SurfaceBlend and Patch.crown are noted under format 22.
2026-10-15 13:32:16 -04:00
HiroKenEz 4e10c31fdd sketch.create: refuse a face (or origin/x_dir/y_dir) next to plane (#1447)
sketch.create refuses a face (or origin/x_dir/y_dir) given next to plane, instead of silently ignoring one of them.
2026-10-15 13:32:10 -04:00
phanoob 3047aa60cd Text cuts: the 2D prism route takes a font's curves exactly (#1429)
The 2D prism route for text cuts takes a font's curves exactly instead of as polylines.
2026-10-15 13:32:02 -04:00
Luke Kearney f0c5bfcdf2 Draft: only the faces round the drafted walls must be walls or caps (#1393)
Draft checks only the faces that share a corner with a drafted wall, so rounds and other faces elsewhere on the body no longer refuse it (#390).
2026-10-15 13:31:51 -04:00
Erikas d8a088f46a STEP export: a half-turn arc end as a cylinder, and cut faces' sheets bounded by their faces (#1198) (#1438)
STEP export writes a half-turn arc end as a cylinder, and cut faces' sheets bounded by their faces (#1198).
2026-10-15 13:31:46 -04:00
Erikas 17b48d9e7e Booleans: planar cuts beside rounds take the exact route before the retries (#1197) (#1445)
Planar cuts beside rounds take the exact route before the boolean retries (#1197).
2026-10-15 13:31:41 -04:00
phanoob 56747edb46 Boolean: a point at a boundary curve's end is that end, whatever its parameter (text cuts, #391) (#1195)
A point within the point tolerance of a boundary curve's end is that end in the boolean, whatever its parameter, so text cuts no longer leave unmatched sliver vertices (#391). Robustness goldens re-recorded for seeds 0, 34, 39, 41 and 57 with reasons.
2026-10-15 13:31:37 -04:00
bherbruck aa31e56477 macOS: ship solidcraft-cli inside SolidCraft.app (#1456)
Every other package (AppImage, deb, rpm, tar, Flatpak, Snap, MSI, portable zip, FreeBSD) already
puts solidcraft-cli next to the app; the macOS bundle only had it in a separate zip. The agent
chat starts it as its agent's MCP server and looks for it beside the running program.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:13:19 -04:00
bherbruck ef53bc46f3 Sketch: the first dimension scales a sketch drawn by eye (#1365) (#1416)
* Sketch: the first dimension scales a sketch drawn by eye (#1365)

Lines drawn by eye have no size of their own until a dimension gives them one, but the first
dimension kept every other drawn length and distorted the sketch to fit (a free construction
line through the origin turned instead of the shape moving). The dimension tool now scales
the whole sketch about its origin to the first length dimension, as long as nothing in the
sketch fixes a size yet: no other driving length dimension, nothing fixed, projected or
patterned. Every geometric constraint and angle holds on the scaled sketch. Later
dimensions size what they measure as before; sketch.dimension does this only with
scale_sketch: true, so scripts are unchanged.

Fixes #1365

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

* Scenario s01: dimension the box's height too, now that the first dimension scales the sketch

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:13:14 -04:00
selune 8b7fa2602e material: report exact missing condition fields (#1334)
Material-card validation now names the exact condition fields missing for each property (missing_condition_fields), alongside the existing unspecified_conditions.

Co-authored-by: 0xlillith <0xlillith@users.noreply.github.com>
2026-10-15 13:10:49 -04:00
DuBearte fd422a3fc1 Dialogs: the Select chip keeps its size on hover (#1387)
New frame::add_outlined adds a button that draws its own outline with the theme's outline width set to the same width in every state, so its padding no longer drops a pixel on hover. Used by the dialogs' Select chip, Sketch Text's Path chip and the bug report's Open GitHub draft button. New dialog_chip_hover test. Fixes the second point of #1335.
2026-10-15 13:01:09 -04:00
HiroKenEz e382a77350 test: read the live crash journal through its own handle (#1450)
startup_steps_survive_a_rebase_and_running_ends_startup reads the open journal through a clone of its own handle (read_journal_file), as read_live_journal does, instead of opening a second handle that Windows' mandatory lock refuses. Test-only. Fixes #1448.
2026-10-15 12:48:08 -04:00
Colin Faulkingham a18bedddf6 Keep New Body and Offset Face previews moving during distance drags (#1238)
New Body extrusions and Offset Face previews show successful intermediate distances from the same setup while a distance drag runs, then compute the latest one (tool previews keep streaming as before; errors still need exact inputs). The drag arrow and value box are painted from this frame's pointer, removing a frame of lag. Drag regression tests and a perf note added.
2026-10-15 12:47:37 -04:00
Mateusz Sych 29e72806f6 Context menus stay in view near the window edge; planes offer Create Sketch (#1398)
List menus open below-right of the cursor and flip to end at the cursor where they don't fit (shared context_menu::place / show for viewport, browser, toolbar and Select Other). Right-clicking a plane in the viewport, a construction plane row or an origin plane row (new menu with Create Sketch and Show/Hide) offers Create Sketch on that plane. Fixes #1354.
2026-10-15 12:47:15 -04:00
Lasse Lian fb55c1341a AppImage: no desktop entry when appimaged made one or integration is switched off (#1406)
AppRun installs nothing when an appimagekit_*.desktop entry for the same AppImage exists, and removes the entry it wrote earlier. An empty ~/.local/share/solidcraft/no-desktop-integration turns the integration off for good; SOLIDCRAFT_NO_DESKTOP_INTEGRATION=1 still works. Getting-started guide documents it. Fixes #1399.
2026-10-15 12:46:53 -04:00
Lasse Lian d4ab9545a1 Scenario harness folder uses the long-name temp dir (#1404)
scenario::scratch_dir builds the harness folder under solidcraft_engine::io::vfs::temp_dir() (long names), so a short 8.3 TEMP on Windows no longer trips the short-name refusal before the scope checks the scenarios test. Fixes #1385.
2026-10-15 12:46:25 -04:00
Mateusz Sych c2ba3657f3 Joints: link offsets in b's units, new joints start inside their limits, loop dof at dead points (#1415)
joint.motion_link offset is read in the linked value's units (degrees or mm, or an expression); links are still stored in radians, so saved designs open unchanged. A new joint whose values fall outside its limits starts at the nearer limit, stores it, and says so in warnings. Loop dof also counts at a nearby pose, so a part at a dead point of its loop keeps its mobility. The mechanisms skill is updated to match.
2026-10-15 12:45:48 -04:00
NOWN-DEV d0a4443939 inspect.compare: where two versions of a part differ, without a boolean of the two (#1423)
New command inspect.compare {a, b, tolerance?}: measures of both and their differences, two-way surface deviation from a bounding-volume tree, regions beyond the tolerance clustered by side (only_in_a / only_in_b) with integrated volumes and faces, a verdict, optional face colouring as one undo step, and warnings for open meshes. Geometry in solidcraft-geom (compare.rs), command in the engine. Closes #1363.
2026-10-15 12:45:43 -04:00
DuBearte 2bfec256b7 Expressions: accept a decimal comma outside parentheses (#1347) (#1382)
"2,5 mm" is 2.5 mm; inside a function's parentheses the comma still separates the arguments. Noted under design format 22: an expression typed with a decimal comma is saved as typed, so 0.12.0 asks for an update instead of failing to evaluate it.
2026-10-15 11:40:50 -04:00
Jan Szumiec e8c340c59d Web: installable as an app, with Install in the File menu (#1233)
The web build ships a manifest so the browser can install it as an app, and offers Install in the File menu while the browser allows it.
2026-10-15 11:34:57 -04:00
Karl Von Richter 20a7dd8764 Chamfer chains ending at existing bevels (#1303) (#1372)
A curved chamfer chain with straight planar ends can end across two earlier planar bevel faces, shortening their shared seam to where the new chamfer plane crosses it; picks on one tangent chain are applied once. Fixes #1303.
2026-10-15 11:34:48 -04:00
Lasse Lian 3fa8fbd162 Mesh edges that do not evaluate without a panic; refuse such boolean results (#1252) (#1364)
The mesher samples edges through a fallible evaluation, so an intersection curve whose projection fails to converge no longer panics; a boolean result with an edge that does not evaluate is refused with an error. Fixes #1252.
2026-10-15 11:10:55 -04:00
bherbruck 27529d9742 Imports become components; STEP assemblies as nested components (#1386) (#1426)
* Imports become components: a STEP assembly's tree as nested components (#1386)

Every imported file (STEP, IGES, 3MF, STL, OBJ: inserted, dropped, opened as a
design, or file.insert_step / file.insert_mesh / doc.open from scripts, the CLI
and MCP) now comes in as a new component named after the file, with its Import
feature inside it. A STEP assembly's products become nested components; a part
placed more than once is one component with an occurrence per placement, and
the Import feature skips the file's later copies (`skip`, format 21 note). The
contents are held as one rigid group (rigid joints, glyphs hidden); moving an
inner part is refused until component.unlock ("Unlock Contents") releases it.
Y up turns the import's component. Replies report component, component_name
and occurrence. Undo is one step; designs with loose imports are unchanged.

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

* Keep an import's rigid group hidden when the window loads the file in the background

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

* Import instances' skip list is format 22: format 21 ships in 0.12.0

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

* Catalog entries for Unlock Contents

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:04:49 -04:00
bherbruck d43c414b7b View-dependent display refinement: finer curved faces and edges as you zoom in (#1320) (#1425)
* View-dependent display refinement: bodies seen up close are drawn from finer display plans (#1320)

Each frame the viewport works out every shown body's screen-space error (the display
quality's chord tolerance in pixels where the body is nearest the eye) and picks a
refinement step past the chosen display quality, each halving the chord, with hysteresis.
Steps are planned off the UI thread and swapped in when ready, within a triangle budget
and a cache size. Faces (GPU plan or CPU mesh), edges, silhouettes and highlights of a
refined body all come from the refined plan, so a highlight stays on the drawn triangles.
Planned bodies are chunked by content on the GPU, so a refinement re-evaluates only its
own chunk. Display only: the reference mesh, the B-rep and exports never change.

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

* Display refinement: only collect finished plans while the view stands still, time the choice, keep the web planning one step a frame

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

* Format

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

* Keep the Display quality note as it was (the longer one pushed the Colour vision list off the Graphics page)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:03:53 -04:00
bherbruck 8b97b9ed62 Number fields take a decimal comma too (#1347) (#1418)
A number field reads "2,5" as 2.5, as a numeric keypad types it in many locales; a comma beside a point or a second comma is not guessed at.
2026-10-15 10:32:14 -04:00
bherbruck 4c7ea34a68 Web app: open and insert STEP files over frames, with progress and Cancel (#1376) (#1424)
* Web app: read a STEP file a piece per frame, with progress and Cancel (#1376)

The kernel's STEP reader walks the file first and converts its solids afterwards,
and StepReading exposes those stages: parse and walk, then convert a solid (or a
batch) per step, then finish into the shared import cache. The web app's file
load drives it from the frame loop for about 30 ms a frame, shows the same
progress card and Cancel as the desktop, and then runs the insert or open, which
finds the file already read. The desktop path is unchanged in behaviour.

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

* Web app: make the imported bodies' meshes a few per frame too (#1376)

After the read, the insert or open runs, and then the bodies' display meshes are
made a frame's worth at a time ("Preparing the view"), as the desktop worker
warms them, instead of all in the first frame that draws the design.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:28:09 -04:00
bherbruck b156b5e09c Insert Design on the ASSEMBLE panel and in the File menu (#1240) (#1405)
component.insert_design (a saved part as a linked component) had no button, so an
assembly of separately saved parts looked impossible. It is now on the ASSEMBLE panel
of every tab and in the File menu; it asks for the file, and a STEP or mesh file picked
there is inserted as Insert STEP or Insert Mesh would.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:28:02 -04:00
bherbruck 4823c293dd A cut from a component without bodies cuts the parts it meets (#1234) (#1407) 2026-10-15 10:27:27 -04:00
bherbruck 762b6fe934 Extrude Cut of a whole face beside round corners: grow the arcs too (#1275) (#1401) 2026-10-15 10:27:22 -04:00
bherbruck 073acd295b Windows: resize border from the first frame, DPI-correct window moves (#1396)
* Windows: resize border from the first frame, DPI-correct window moves (#1381)

- The egui resize zones were laid out after the start page's early return, so a launch without
  a design had no resize edges at all. They now run on every page.
- Windows: the main window's procedure answers WM_NCHITTEST with the resize edges and corners,
  Windows' own border width at the window's DPI (8 px at 100 %), so the OS shows its resize
  cursors and runs its own resize (snap included) from the first message; no egui zones there.
- Windows: WM_GETDPISCALEDSIZE returns the size winit then applies (same logical size), so the
  rect the OS suggests while the title bar is dragged onto a monitor with another scale is the
  rect the window gets.
- Zone sizes and hit test shared in titlebar.rs and unit-tested; Windows CI checks the hit test
  right after launch and a real edge drag.

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

* Windows resize smoke: find the main window by its title

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

* Windows resize smoke: probe the maximized window at the screen's edge

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:27:21 -04:00
bherbruck 1744fbb262 Measure: centre-to-centre for holes, diameters for circles and arcs, slot sizes (#1392) (#1419)
Measure now reads the picked item's exact shape (cylindrical faces, circular and arc edges,
sketch circles, arcs and lines, profiles, planes, points):

- A circle, hole or arc shows Ø, R and its length (circumference, or arc length and angle).
- A slot (a sketch profile, or a half-cylinder end of a slot in a solid) shows its width,
  centre-to-centre length and overall length.
- Two items where one is round measure from the centre or axis by default (axis to axis,
  axis to plane, edge or point), with the closest and farthest distances listed too; the
  panel switches between centre, min and max, and the canvas line follows.
- inspect.measure and MCP `measure` (new `items` argument) add diameter, radius,
  center_distance, min and max; existing fields are unchanged.

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

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

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

* Tests: no unreachable! in the blend arrow helper

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

---------

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

Thanks to @kuraneko1 for this.

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

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

Fixes #1361

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

Preferences ▸ Graphics ▸ Supersampling: Off (default), 1.5× or 2×. The frame is drawn into
textures of the viewport's own kind (format, depth/stencil, MSAA) at that multiple of its pixels,
over transparent black, then filtered down into the viewport with four bilinear taps (the exact
2 × 2 box at 2×) and composited premultiplied over the background. Lines keep their on-screen
width; silhouettes and edges come out smoother.

Adaptive (on by default): the view is drawn at full speed while the camera moves and
supersampled once it has held still for a quarter of a second.

The supersampled size stays within the device's largest texture side (#869) and a 384 MiB
budget for colour and depth (MSAA samples included); under 1.25× is not worth it and the frame
is drawn as usual.

Labels in en/de/ja/ko/zh; `ui.prefs` `supersampling` and `supersampling_adaptive`.

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

* Supersampling: factor and adaptive on one row; Off reads Off (1×)

The Graphics page grew past the colour vision menu in the preferences scenario, and a second
Off on the page took its click.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 03:21:55 -04:00
NOWN-DEV b3df2c382b snapshot --camera and saved views; grid: false / axes: false for headless renders (#1313)
solidcraft-cli snapshot takes --camera JSON and --view with a saved view name (through view.apply, as the MCP screenshot does), plus --no-grid and --no-axes; the MCP screenshot and ui.render take grid/axes, the headless render skips a hidden origin axis, and the app's ui.render follows the Grid and Origin switches. Closes #1245.
2026-10-15 02:59:56 -04:00
NOWN-DEV d824d36821 Convert to Sheet Metal takes bent parts as modelled: radius and chamfer bends, holes (#1314)
A Convert to Sheet Metal bend point can now lie on a bend modelled with its radius (a cylinder tangent to two flat faces) or in the middle of a chamfer; either becomes one bend at the faces' virtual sharp, with the rule's radius. Holes and engravings carry over. Bend points on edges convert as before. Recorded under format 21. Tests in cmd::sheet::convert_tests; the sheet-metal guide is updated.
2026-10-15 02:54:09 -04:00
Mateusz Sych 849426e866 Drive Joints opens on the picked joint; ui.start takes params (#1297)
Drive Joints opens on the joint picked in the browser, else the first joint that moves, and won't start on a rigid joint. ui.start takes params: joint.drive opens on a joint (id or name) at given values, driving nothing until OK; a command or param it doesn't take is refused.
2026-10-15 02:54:02 -04:00
NOWN-DEV 0ee5e97db5 STEP import takes up for Y-up files and names for the bodies' names (#1312)
file.insert_step and doc.open on a STEP file take `up` (turn a Y-up file so its up is +Z) and `names` (names for the imported bodies). The MCP tool and the app's control requests pass them through; an import without them works as before. Tests in the kernel's STEP reader, the engine, MCP and the file-load UI; docs/step-import.md and docs/mcp.md describe both.
2026-10-15 02:18:55 -04:00
Mateusz Sych 81d9b7e04a Agent skill for mechanisms: joints by coordinates, loops, links, motion (#1315)
A solidcraft-mechanisms agent skill with a worked example that a test replays through the MCP server (the actuator follows the hinge with the stated stroke and the drive stops at the rod's limit); the skills index and docs/mcp.md list it.
2026-10-15 02:13:48 -04:00
DuBearte 6ef0b22118 Viewport: a legend for the Draft, Accessibility and Curvature Map analyses (#1299)
While Draft, Accessibility or Curvature Map analysis is on, a legend at the bottom left of the viewport says what each colour means and which values it stands for, with the theme's analysis colours and stripes. Labels translated in en/de/ja/ko/zh. Fixes #973.
2026-10-15 02:13:44 -04:00
DuBearte 873a65fd4d SpaceMouse: on Linux, default to the driver's object-mode axes (#1292)
Without a 3DxWare profile, Linux now uses the driver's object-mode mapping (physical Ry and Rz swapped and reversed), so a tilt rolls and a twist spins. Windows and macOS are unchanged; a profile still takes precedence. Fixes #1172.
2026-10-15 02:13:22 -04:00
Mateusz Sych 1473893b08 MCP: a bridged server starts without the app and connects once it runs (#1298)
The bridged MCP server starts when no app is listening, says so in its instructions and in each tool error, and connects on the first call after the app starts. A batch that could not be sent says the app is not running. docs/mcp.md describes it.
2026-10-15 02:13:18 -04:00
Mateusz Sych 96356dc544 CLI snapshot takes --in; an unknown command suggests close ids (#1301)
snapshot starts from --in (a design or STEP file) with the script on top, as run does, and refuses options it doesn't take; an unknown command id names the closest registered ids.
2026-10-15 02:13:14 -04:00
Mateusz Sych 50c5c13f72 ui.camera: read, set and replay the viewport camera (#1302)
A control-channel method that reads the orbit camera, sets any of target/yaw/pitch/roll/distance, and fits listed occurrences (or everything) keeping the direction. Its reply passed back sets the same view again; a refused field changes nothing.
2026-10-15 02:13:09 -04:00
Mateusz Sych 7195e323bf Joint drives say when they move nothing; drive values in degrees; quiet replies (#1304)
joint.drive answers moved, and a reason (with unconnected) when the joint can move nothing; motion.play answers moved and unmoved; joint.list answers floating chains. joint.drive values come back in joint.list's shape (deg/mm). quiet: true trims joint answers and motion.play poses. A single value keeps a joint's other values. Drive Joints shows the floating case (drive.unconnected, all locales).
2026-10-15 02:12:58 -04:00
Mateusz Sych fd19efc6fc component.list: a compact form for large assemblies (#1305)
component.list {compact: true} gives counts of bodies, features, sketches and construction planes instead of their names, lists occurrences without identity transforms and children as occurrence ids; bodies: true keeps body names. The default form is unchanged.
2026-10-15 02:12:48 -04:00
bherbruck 9eddb1cbd3 Preferences ▸ Graphics ▸ Display quality (Coarse/Normal/Fine/Ultra) (#1287) (#1319)
* Preferences ▸ Graphics ▸ Display quality: Coarse, Normal, Fine, Ultra (#1287)

A viewing preference that sets how finely the viewport tessellates curved faces and edges:
chord tolerance relative to each body's size (3e-3, 1e-3, 3e-4, 1e-4) and the most a facet may
turn (30, 20, 12, 7 degrees). Normal is the tolerance the viewport has always used.

Bodies keep a display plan per level, so switching is a re-plan the first time and instant
after; the reference mesh the model reads (picks, holes, measuring) and exports are unchanged.
Away from Normal the model scene builds edges, and faces on the CPU path, from the plan at that
level, so silhouettes and edges get finer with the faces and a highlight still lies on the
displayed triangles.

The preference is saved with the others, set over the control channel (`ui.prefs`
`display_quality`), labelled in en/de/ja/ko/zh. `solidcraft-cli bench-open --display LEVEL
[--edit NAME EXPR]` times the plans at a level and after a parameter edit.

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

* Highlighted edges follow the display quality too

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:06:49 -04:00
NOWN-DEV 47517578a8 Test: an inside flange on a turned plate closes at every whole-degree turn (#1311)
A regression test: an inside flange on a 100 x 60 plate turned by every whole degree from 0 to 89 builds and keeps the analytic volume. At 62 degrees the trimmed corner used to land on a border of the sew's vertex grid and the flange failed to close.
2026-10-15 02:05:18 -04:00
Karl Von Richter d6f167a88a Fix boss-bottom blends and merged arc-corner surfaces (#1294)
A fillet or chamfer on a cylindrical boss's bottom arc now builds with a closed display mesh: the seam-extension containment ray counts a shared curved-boundary endpoint once. Merging the split faces of a three-edge arc corner rebuilds the face on the original input face's full surface instead of a piece's restricted patch, and runs under the kernel's panic guard. Fixes #1262 and #1263.

Co-authored-by: Karl-VonRichter <20079846+Karl-VonRichter@users.noreply.github.com>
2026-10-15 02:03:31 -04:00
Raymond Thurman af2f09fac2 A new empty design takes Units of new designs (#1283)
The empty design shown at launch, New on a design that is still empty, file.new and the blank design left when the last tab closes all take the Units of new designs preference. Opened files and the sample part keep their own units. Fixes #1205.

Co-authored-by: raythurman2386 <raythurman2386@users.noreply.github.com>
2026-10-15 01:20:02 -04:00
DuBearte 7391e8ed75 Section: leave the origin and construction planes and axes whole (#1286)
Section Analysis clipped the origin and construction planes and axes along with the model. They now draw from their own unclipped lists, with the same depth and blending; bodies, edges, sketches and highlights are clipped as before. Fixes #1229.

Co-authored-by: DuBearte <DuBearte@users.noreply.github.com>
2026-10-15 01:19:59 -04:00
Karl Von Richter 1ec33fcdc7 Extrude: a symmetric distance is the total thickness (#1260)
A symmetric solid extrude of 10 mm is now 10 mm thick, 5 mm each side of the sketch plane, and switching from One Side to Symmetric keeps the thickness entered. Under format 21 the extent records its convention; designs saved without it keep the per-side reading, so their geometry and parameters don't change. Fixes #1236.

Co-authored-by: Karl-VonRichter <Karl-VonRichter@users.noreply.github.com>
2026-10-15 01:19:55 -04:00
Giovanni faeeff1c64 Blends: a curved seam where the edge chain runs on holds the contact on itself (#1291)
A round or chamfer whose edge chain runs on across a curved seam (the end circle of a lofted tube whose axis bends) is now built: the section solve keeps its contact on the seam through a plane taken afresh at the seam's nearest point at each step. Straight seams are unchanged. Robustness seed 41 is re-recorded: its second fillet, refused before, now builds. Fixes #1213.

Co-authored-by: gvocale <gvocale@users.noreply.github.com>
2026-10-15 01:19:51 -04:00
Karl Von Richter ee9b4b9fb1 Kernel: refuse arc blends that collapse a side face (#1281)
A fillet or chamfer that consumes both seams of a side, leaving its old boundary and the fitted contact on the same curve, now returns TooLarge ("the blend would consume a neighbouring face") instead of a zero-area face that doesn't mesh. Genuine two-edge faces are kept. Fixes #1113.

Co-authored-by: Karl-VonRichter <Karl-VonRichter@users.noreply.github.com>
2026-10-15 01:19:48 -04:00
bherbruck 8535d229ff Display helical-trimmed faces from imported threads correctly (#1289) (#1300)
A modelled thread as other CAD systems write it to STEP has cylinder faces trimmed by helices
that turn several times around the shaft, and cylinder faces bounded by two circles with no
seam edge. The display planner refused any face whose boundary winds around its surface, so
the whole body fell back to the reference mesher, which drew those faces with triangles across
the solid.

The planner now cuts such a face along a parameter line into one period: every loop and its
copies a period apart are clipped to the strip, and the parts of the cut line inside the face
join the pieces into closed loops, with the seam nodes made once for both sides. The cut face
is meshed by Delaunay with sag refinement, and the sag check runs once more after shaping.
Bodies with a face trimmed by more than one turn take their reference mesh (picking, measuring,
the CPU viewport) from the plan too.

Tests: an ISO M10x1.5 and M12x1.75 thread built from helices, as made and after a STEP round
trip: the display mesh stays within the tolerance of its surfaces and the thread's volume, and
has no degenerate, sliver or flipped triangles.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:51:30 -04:00
bherbruck 5c2fa3e9ca Action matrix regressions of #1255: knuckle rounds, thin holes, a 45° flange hole, an axis after an edit (#1280)
* Action matrix regressions of #1255: knuckle rounds, thin holes, a 45° flange hole, an axis after an edit

- Curve blends: past the end of a curved edge the run of sections along the end
  tangent wrapped round the ring of centres; it is shortened while it still
  crosses the end face (the hinge knuckle's cap rounds at 0.728 mm).
- Boolean retry guard: each body's volume is measured finer than its smallest
  radius, and a volume out of bounds is measured again ten times finer before
  it refuses (a 0.133 mm drill measured -30272 mm³; a plastic part's coarse
  mesh was off by more than the slack).
- Sheet metal: a drill cap's certificate allows the evaluation noise its fitted
  tilt shows (a hole square to a 45° flange).
- Construction geometry on a named face uses the face found by name, not a
  second pick at its nearest point (an axis through a corner round after a
  parameter moved it).

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

* Retry guard: measure the target, tool and result at one tolerance

Measured at different tolerances, the chords' error on the faces they share
didn't cancel: a blind hole in a long D-profile bar measured 20 mm³ more than
the bar.

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

* Action matrix baseline from this branch's full run: 37 actions pass again, none fewer

Blessed with cargo xtask action-matrix --from DIR --bless from the 16 shards of
run 38673859774.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:24:27 -04:00
JIJINBEI 464a6dfad4 Feature walks: generate them from field roles with #[derive(Walk)] (#1251)
The three walks over a feature (expressions, references, geometry) are generated from FeatureKind by #[derive(Walk)] in a new proc-macro crate, solidcraft-derive, from #[walk(...)] attributes written next to each field. The hand-written walks in params.rs, feature_refs.rs and feature_geometry.rs are gone; Ruled Surface and Extend have their attributes, and the three golden walk records are unchanged.

Co-authored-by: jijinbei <jijinbei@users.noreply.github.com>
2026-10-15 00:13:53 -04:00
DuBearte a026a69182 Sketch: fillet and chamfer a corner of curves drawn to meet at a point (#1282)
A corner whose two curves end on separate points held together by a Coincident constraint (a line drawn to where an arc starts) is welded into one shared point before Fillet or a Chamfer rounds or cuts it, so it no longer reports that the corner must join two lines. Only a partner point held by nothing but that constraint is welded. Fixes #1208.

Co-authored-by: DuBearte <DuBearte@users.noreply.github.com>
2026-10-15 00:02:00 -04:00
bherbruck 406293fd53 Title bar: inactive tabs without outlines, File menu, Help on the right (#1277)
* Title bar: inactive tabs without outlines, File menu, Help on the right

Inactive tabs go back to the plain bar colour (the Home tab's readable
hover stays). The separate Home button and theme toggle are gone: Home is
a tab and the theme is in Preferences. The folder icon becomes a File text
menu, Save moves into it (Ctrl+S unchanged), and Help sits on the right
next to Report a bug. Tabs start right after File, Undo and Redo.

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

* Title bar: File button hover uses the app bar hover token

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:18:48 -04:00
Emil Petersson bd8db8ea1c Add Touchpad navigation: Shift to pan, Alt/Option to orbit (#1220)
An opt-in Touchpad navigation preset: Shift + pointer motion pans, Alt/Option + motion orbits, vertical scroll zooms, all without a mouse button. Existing presets are unchanged; view.navigate gains continuous motion for the control channel and scenarios.
2026-10-14 22:46:33 -04:00
Irakli Kourtis b4edf05373 Extend any surface: natural, tangent and curvature, up to a target (#760) (#1166)
Extend works on any surface: natural (continuing the face's own surface), tangent (G1 run-off) or curvature (G2 run-off), by a distance, up to a plane or face, or back to the surface's bounds, with neighbouring edges lengthened to meet. Untrim and Extend share face parameter-loop helpers. The new SurfaceExtend fields are noted under format 21. Closes #760.
2026-10-14 22:46:29 -04:00
William Dunn 41f0c3d0c6 docs: browser runtime design (craft-libs, workers, #1221) (#1258)
A design note for SolidCraft in the browser: tiered storage, a SharedWorker job broker, GPU dispatch and in-browser agent control through the same engine commands, with the generic pieces in craft-libs. Linked from the MCP and web getting-started docs.
2026-10-14 22:34:48 -04:00
DuBearte 784648af2a Loft: refuse a section with a hole instead of filling it (#1184) (#1254)
A loft section with a hole is refused with a clear message instead of being lofted as if filled, in both section-to-section and section-to-point lofts. Fixes #1184.
2026-10-14 22:34:43 -04:00
DuBearte 38bd16713f Header: size the Report a bug button to its translated label (#1145) (#1274)
The labelled Report a bug button takes its translated label's width, so longer translations no longer run over the Preferences gear; narrow bars still fall back to the compact icon. Fixes #1145.
2026-10-14 22:34:37 -04:00
DuBearte b1695873f5 Browser: show given names as typed, not translated as labels (#1228) (#1264)
Browser rows that are names someone gave (bodies, sketches, components, joints, views...) show as typed instead of being translated as UI labels; default names like Sketch1 are still translated. Fixes #1228.
2026-10-14 21:45:13 -04:00
DuBearte c008addae2 Parameters: outline the Delete button only on hover (#1178) (#1241)
The Delete button in each parameter row shows its outline on hover, like the fields beside it; the redundant tooltip is gone. Fixes #1178.
2026-10-14 21:45:09 -04:00
DuBearte 9dc12b4d0f Parameters: reset the new row's expression after Add (#1170) (#1235)
After Add in Change Parameters, the new-parameter row's expression goes back to the default a new row starts with. Fixes #1170.
2026-10-14 21:45:04 -04:00
Irakli Kourtis 2a4907884b Ruled Surface: strips off an edge chain, exact where possible (#762) (#1151)
Ruled Surface: strips off an edge chain in normal, tangent, angle or direction mode, exact planes/cylinders/cones where possible, mitred or round corners, stitched into one surface body. SurfaceRuled is noted under format 21. Closes #762.
2026-10-14 21:44:56 -04:00
bherbruck ed22d663e4 Smart Extrude: direction from the picked side, and a Cut stays a Cut (#1257) (#1261)
* Smart Extrude: direction from the picked side, Cut into a body stays a Cut (#1257)

- A sketch profile (or a thin extrude's curve) extrudes toward the side it was
  picked from: from the bottom view it goes down. Body faces still go outward.
- The automatic operation is latched per side of the profile: a drag that starts
  into a body is a Cut and stays one past the far side; a Join does not become a
  Cut when it reaches another body. Passing through zero decides afresh for the
  new side.
- An explicit operation, Direction or Reverse ends the automatic choices.
- auto_operation also looks just past the profile, so a long cut through a body
  reads as a Cut from the start (the engine's operation: "auto" too).

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

* Scenario s07: the XZ profile seen from behind now extrudes toward the camera

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:31:10 -04:00
bherbruck d73cd8a177 Robustness goldens: record seed 13 after #1164 (#1268)
#1164 joins a body that sits flush on a rounded rim into it. In seed 13 the
rectangular pattern's second box (x 5..7.5) sits flush on the half-ring's top
face with a 27 mm2 contact patch. It used to stay a separate shell (one body,
three shells); now it joins the ring (area -54 mm2, twice the patch). The
fillet edge and hole position the generator picks next come from the new
geometry, so the end state differs: 3 bodies, 2647.95 mm3 (was 4, 2300.91).
Every step stays a sound solid, and no other seed moves.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:54 -04:00
bherbruck c159a3e277 Action matrix: nightly shards cut by measured cost, a fresh baseline, and a determinism check (#1256)
* Action matrix: nightly shards cut by measured cost, heavy designs in slices, and a determinism check

The full matrix ran as four fixed quarters of the corpus and two of them hit the 60-minute
job limit. Now action_matrix_shard runs shard k of n (ACTION_MATRIX_SHARD=k/n), planned from
tests/action_matrix/costs.json: designs that take more than five minutes are split into slices,
and the pieces go largest first to the least loaded shard. The nightly runs 12 shards and uploads
each one's outcomes and timings; cargo xtask action-matrix --from DIR --bless records them in the
baseline and costs.json without running the matrix again. action_matrix_determinism runs a sample
twice and lists any action whose outcome changed.

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

* Action matrix: costs measured on runners, 10 shards, determinism on the lighter designs

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

* Action matrix: baseline and costs from a full run on main, 16 shards, timeouts listed

The baseline is regenerated from the 10 shards' results of a full run on 01b1b705
(cargo xtask action-matrix --from DIR --bless): 8 new designs, 302 actions that pass now,
names that changed, and 40 actions that passed before and fail now, each listed in #1255
(22 fillets refused since #949, 4 hinge blends since #1101, 9 on the loft cut, 5 single).
costs.json now holds every design's measured time, and the nightly runs 16 shards of about
17 minutes. An action that passes in the baseline and times out is listed but not judged,
and stays passing when blessed: two runs of the same build differed only on 10 actions that
took 50-84 s against the 90 s limit.

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

* Action matrix: slice heavy designs by a hash of each action's name

Slicing by index gave some slices only fillets on oracle 41 (28 slices, a multiple of the
actions per edge), three times the planned time; two shards ran past their limit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:11 -04:00
Irakli Kourtis 16c49c3e86 Untrim: holes removed and a face restored to its natural bounds (#1150)
Adds surface.untrim (#761); the SurfaceUntrim feature is first saved in format 21.
2026-10-14 20:29:25 -04:00
DuBearte 734f69ad72 Parameters: arrow keys move the cursor while renaming (#1248)
Fixes #1165.
2026-10-14 20:20:21 -04:00
NOWN-DEV 2e7f41710b run and eval can write their JSON to a file (--json-out) (#1242) 2026-10-14 20:18:17 -04:00
NOWN-DEV 5c6f70cc00 Faces by name in the face commands; model.faces gives a point on each face (#1243) 2026-10-14 20:18:13 -04:00
NOWN-DEV 2cab09f65b Join keeps its tool exact where the tool ends in the part's faces (#1244) 2026-10-14 20:18:09 -04:00
Igor Bronovskyi 861ab027b6 Add Ukrainian localization (uk) (#1196)
Ukrainian UI catalog; untranslated keys fall back to English.
2026-10-14 20:10:45 -04:00
phanoob 9935133b56 Live preview: the tool alone, the boolean only on OK, computed off the window thread (#1201)
The preview builds only the tool; the boolean runs once on OK, off the window thread, with a Computing label while it takes a while. Follow-up to #1020.
2026-10-14 19:31:29 -04:00
selune a261aaa841 Render: integrated workspace, preview image jobs and opaque PBR (#1046)
Lands the Render workspace (R3), bounded preview-image jobs with cancellation and atomic PNG export (R4a), and opaque PBR looks as one change.

- Render workspace: an overlay-free product preview on the installed GPU device. Returning to Design restores its camera. render.scene and render.settings.apply are added.
- Image jobs: render.preflight/start/status/cancel/export run bounded CPU preview jobs. Export captures the job, overwrite choice and document, and checks the caller's file scope before writing.
- Opaque PBR: looks can carry metal fraction, roughness and dielectric IOR. The product viewport and preview PNG use them; geometry and mass are unchanged. The v1 catalog export refuses PBR rather than dropping it.
- Render setups and PBR looks are saved under design format 21. Labels are in en/de/ja/ko/zh/ru.

Supersedes #706 and #800. Refs #620, #673, #756, #1004.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-14 19:24:36 -04:00
rlartot e4ef6cfe29 French (fr) UI localization (#1224)
Adds a French catalog as a partial locale (missing strings fall back to English, like Russian), registered in the language list, with tests and attribution.
2026-10-14 19:05:17 -04:00
Que 1cc65cbc91 fix: a lip swept flush around a rounded rim joins the body it sits on (#1164)
fix: a lip swept flush around a rounded rim joins the body it sits on.
2026-10-14 18:49:45 -04:00
Mateusz Sych 099d7e298a Degrees of freedom count closed loops (#1206)
Degrees of freedom count closed loops.
2026-10-14 18:46:46 -04:00
Tobias Breckle a629236650 Join a floor into the hole it fills: grow the boss into the wall around it (#1212) (#1217)
Join a floor into the hole it fills: grow the boss into the wall around it (#1212).
2026-10-14 18:28:17 -04:00
Lasse Lian a4a8b48567 Design properties: project, author and licence in the design, its exports and inspect_design (#1015) (#1189)
A design carries metadata (format 21): project, part number, revision, author, organization, description and license, plus other tools' data under reverse-DNS keys, kept as written. Edited in the Design Properties window (Document Settings), set by document.metadata.set, reported by inspect_design, and written into STEP, IGES and 3MF where the format has a place for them. Translated, with tests.
2026-10-14 18:25:35 -04:00
devrosx 78079587ad Build bodies on the CPU on AMD FirePro under Metal (GPU hang on Mac Pro 2013) (#1171)
The AMD FirePro D300/D500/D700 Metal driver hangs on the tessellation compute shader and macOS resets the GPU, freezing the desktop. Bodies are built on the CPU on that device, and SOLIDCRAFT_CPU_TESS=1 forces the CPU path on any GPU. The check is a tested predicate, and the troubleshooting guide documents the variable.
2026-10-14 18:25:31 -04:00
Mateusz Sych 01b1b70586 Drive Joints says where and why a drive stopped short (#1169)
Drive Joints says where and why a drive stopped short.
2026-10-14 17:56:21 -04:00
Erikas 5744b1f327 Test: a pattern of touching cuts builds without fallback (#400) (#1144)
Test: a pattern of touching cuts builds without fallback (#400).
2026-10-14 17:56:17 -04:00
HiroKenEz 1a67390a53 body.rename: renaming a counted body keeps later counted names (#1109) (#1199)
body.rename: renaming a counted body keeps later counted names (#1109).
2026-10-14 17:56:12 -04:00
HiroKenEz 1337404c06 Sketch fillet and chamfer: a construction line at the corner is not a side (#1026) (#1176)
Sketch fillet and chamfer: a construction line at the corner is not a side (#1026).
2026-10-14 17:56:07 -04:00
Lasse Lian 8f081679bc Equal cylinders crossing each other no longer refuse a cut (#788) (#1179)
Equal cylinders crossing each other no longer refuse a cut (#788).
2026-10-14 17:56:02 -04:00
phanoob beb3ea01e7 Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept) (#1204)
Taper: the review of #1014 (spline sides follow their offset, no silent limits, face names kept).
2026-10-14 17:55:58 -04:00
Mateusz Sych c715986a2a Motion links carry a value through a chain stored in any order (#1203)
Motion links carry a value through a chain stored in any order.
2026-10-14 17:55:54 -04:00
bherbruck d20b0fab65 Native crash capture on Windows, and startup steps in crash reports (#1215)
A crash inside a graphics driver left only "an unclean exit between
commands": no backtrace, no module, no hint that it was at startup.

- The crash journal now keeps the startup step the app is in (window,
  graphics instance, surface, adapter request, adapter chosen, device
  creation, renderer and viewport shader compiles, surface configure,
  first frame and its present, running) and the renderer as soon as the
  adapter is picked. egui-wgpu tells the app about its steps through a
  small hook. An unclean exit before the first frame names the step.
- Windows: an unhandled-exception filter (src/native_crash.rs, the one
  Windows-only module allowed unsafe) writes the exception code, the
  faulting module and offset, and a minidump next to the journal; the
  next start puts them into the report.
- The Windows CI job runs a child that faults on purpose and checks the
  report.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:52 -04:00
Tobias Breckle 0ddd660089 Copy a feature's error or warning message from the history menu (#1226)
The feature menu in the timeline and Feature History gets a Copy Message item for a feature's error or warning, translated, with a test.
2026-10-14 17:50:04 -04:00
CamWight b6e87f0c44 Two tests pass on a Windows checkout: a CRLF parse and an 8.3 TEMP path (#1186) (#1211)
Make two tests pass on a Windows checkout: a CRLF parse and an 8.3 TEMP path.
2026-10-14 17:36:07 -04:00
CamWight 731629a422 A failed feature has a mark of its own in the timeline and the Feature History (#972) (#1180)
A failed feature gets its own mark in the timeline and Feature History, distinct from warnings, with tests.
2026-10-14 17:36:02 -04:00
CamWight f6a47a49d7 Colour vision modes: Protan, Deutan and Tritan in Preferences › Graphics (#778) (#1007)
Colour vision modes (Protan, Deutan, Tritan) in Preferences > Graphics, with palettes, shader and icon colours adapted per mode, translations and tests.
2026-10-14 17:35:56 -04:00
selune 289b521a65 Render R2: persist named render setups without re-evaluating geometry (#667)
render.settings.set saves validated named render setups (camera, output size, exposure, environment) as one undo step, and render.settings.get reads them back without applying a camera. When validated render settings are the only change, the engine keeps the evaluated model instead of rebuilding it. Setups are saved under design format 21; format-20 designs read as having none. Fixes #658; refs #620.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-14 17:11:39 -04:00
bherbruck 771a11feab Submit published releases to WinGet (#1222)
Publishing a non-prerelease release runs winget.yml, which opens the
winget-pkgs pull request for bherbruck.SolidCraft with Komac (pinned
2.16.0 binary, SHA-256 checked) from the three versioned MSIs. It skips
cleanly without the WINGET_TOKEN secret or before the package's first
version is in winget-pkgs. docs/releasing.md has the one-time token
setup; the README's Download section gains the winget install line.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:07:06 -04:00
bherbruck 95ea05e7c8 Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

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

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

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

* CI: a test filter YAML parses

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:41:27 -04:00
selune 241dac1542 Render: bounded PNG/JPEG texture input admission (#909)
Bounded in-memory PNG/JPEG texture decoding with explicit base-colour/data-map roles; RGBA8 pixels are published only after a complete decode. Input only: no material assignment, GPU shading or persistence. Refs #900.

Contributed by @0xlillith.

Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com>
2026-10-14 16:35:52 -04:00
436 changed files with 64653 additions and 6118 deletions
+48 -8
View File
@@ -205,16 +205,17 @@ jobs:
action-matrix:
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
# the whole matrix runs nightly (or by hand), a quarter per job.
name: Action matrix (${{ matrix.part }}/4)
# the whole matrix runs nightly (or by hand) in shards of about equal cost, cut by
# tests/action_matrix/costs.json. Keep SHARDS in step with the matrix below.
name: Action matrix (${{ matrix.shard }}/16)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 60
timeout-minutes: 55
strategy:
fail-fast: false
matrix:
part: [1, 2, 3, 4]
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
@@ -223,12 +224,48 @@ jobs:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run a quarter of the matrix
- name: Run one shard of the matrix
# A step limit below the job's, so the outcomes so far are still uploaded.
timeout-minutes: 45
env:
PART: ${{ matrix.part }}
SHARDS: 16
SHARD: ${{ matrix.shard }}
OUT: ${{ github.workspace }}/action-matrix
run: |
mkdir -p "$OUT"
export ACTION_MATRIX_SHARD="$SHARD/$SHARDS" ACTION_MATRIX_RESULTS="$OUT/results-$SHARD.json" \
ACTION_MATRIX_TIMINGS="$OUT/timings-$SHARD.jsonl" ACTION_MATRIX_WORKDIR="$OUT/raw-$SHARD"
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_shard)"
- name: Upload the outcomes and timings
# `cargo xtask action-matrix --from DIR --bless` (with every shard's files in DIR)
# records them in the baseline and costs.json.
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: action-matrix-${{ matrix.shard }}
path: action-matrix
retention-days: 14
action-matrix-determinism:
# A sample of the matrix run twice on one runner: every action must come out the same.
name: Action matrix determinism
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run a sample twice
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --run-ignored only -E "binary(action_matrix) & test(=action_matrix_full_$PART)"
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
doc-tests:
name: Doc tests
@@ -328,12 +365,15 @@ jobs:
with:
key: windows-startup
- name: Backend order and probing on Windows
run: cargo test -p solidcraft --profile ci --locked -- startup::tests
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests
- name: Build the app
run: cargo build -p solidcraft --profile ci --locked
- name: Start the app under different settings
shell: pwsh
run: tools/windows-startup-smoke.ps1 -Exe target/ci/solidcraft.exe
- name: Resize border from the first frame
shell: pwsh
run: tools/windows-resize-smoke.ps1 -Exe target/ci/solidcraft.exe
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
+77
View File
@@ -0,0 +1,77 @@
# Publishing a GitHub release (not a prerelease) opens a pull request at microsoft/winget-pkgs
# that adds the new version of bherbruck.SolidCraft, so `winget install bherbruck.SolidCraft`
# and `winget upgrade` get it. Komac (https://github.com/russellbanks/Komac) downloads the three
# versioned MSIs, reads their product codes and copies the rest from the previous manifest.
# Skipped until the owner adds the WINGET_TOKEN secret, and while the package isn't in
# winget-pkgs yet; see docs/releasing.md.
name: WinGet
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: Published release tag to submit (e.g. v0.11.1)
required: true
permissions:
contents: read
concurrency:
group: winget
cancel-in-progress: false
env:
PACKAGE_ID: bherbruck.SolidCraft
KOMAC_VERSION: 2.16.0
KOMAC_SHA256: 7d2707fa6210f2789a3702de49fbd150b736dbf426ee0b9bc8e098736f9fd82d
jobs:
submit:
runs-on: ubuntu-latest
steps:
- id: check
name: Check for the token and the package
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
if [ -z "$WINGET_TOKEN" ]; then
echo "No WINGET_TOKEN secret: nothing to submit."
exit 0
fi
case "$TAG" in
v[0-9]*.[0-9]*.[0-9]*-*|*nightly*) echo "$TAG is a prerelease: not submitted."; exit 0 ;;
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::Unexpected tag '$TAG'"; exit 1 ;;
esac
if ! gh api "repos/microsoft/winget-pkgs/contents/manifests/b/bherbruck/SolidCraft" --silent; then
echo "$PACKAGE_ID isn't in winget-pkgs yet: nothing to update."
exit 0
fi
echo "version=${TAG#v}" >>"$GITHUB_OUTPUT"
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Install Komac
if: steps.check.outputs.version != ''
run: |
archive="komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSLo "$RUNNER_TEMP/$archive" \
"https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/$archive"
echo "$KOMAC_SHA256 $RUNNER_TEMP/$archive" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" komac
- name: Open the winget-pkgs pull request
if: steps.check.outputs.version != ''
env:
GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
VERSION: ${{ steps.check.outputs.version }}
TAG: ${{ steps.check.outputs.tag }}
run: |
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG"
"$RUNNER_TEMP/komac" update "$PACKAGE_ID" --version "$VERSION" \
--urls "$base/solidcraft-$VERSION-windows-x64.msi" \
"$base/solidcraft-$VERSION-windows-x86.msi" \
"$base/solidcraft-$VERSION-windows-arm64.msi" \
--release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/$TAG" \
--submit
+11 -3
View File
@@ -27,9 +27,13 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is two functions in
`apps/solidcraft/src/open_documents.rs` (that crate denies it instead): macOS hands Finder files
only to an Objective-C method.
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
@@ -77,6 +81,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
camera input (a test rejects it).
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
+6 -1
View File
@@ -14,6 +14,9 @@ Editable drawing code, native projects and shipped exports are collected under `
| File | Author | Source | Licence |
|---|---|---|---|
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
@@ -89,9 +92,11 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
@@ -146,7 +151,7 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
+7 -3
View File
@@ -27,9 +27,13 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The one exception is two functions in
`apps/solidcraft/src/open_documents.rs` (that crate denies it instead): macOS hands Finder files
only to an Objective-C method.
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers) and the Windows-only
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
test of the window without OS decorations).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
+9 -2
View File
@@ -107,14 +107,21 @@ The [README](README.md#architecture) has the layer table. The short version:
`cargo xtask assets` enforces this.
4. **Never crash.** People trust a CAD app with their work.
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events),
and the compiler denies it.
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events)
and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler
denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- 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,
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
`navigation::handle` (`crates/ui-egui/src/navigation.rs`) for its camera input, with
`NavCaps` saying whether it can orbit (off for flat 2D pages) and pick. New canvases must use
`navigation::handle`; never read drags, the wheel or pinch to move the camera yourself. The
`hand_rolled_camera_input_is_rejected` test fails if a page does.
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
the most complete description of the house style.
Generated
+436 -36
View File
@@ -24,7 +24,7 @@ dependencies = [
"atspi-common",
"phf",
"serde",
"zvariant",
"zvariant 5.15.0",
]
[[package]]
@@ -86,7 +86,7 @@ dependencies = [
"futures-lite",
"futures-util",
"serde",
"zbus",
"zbus 5.19.0",
]
[[package]]
@@ -123,6 +123,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -280,6 +291,17 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -398,11 +420,11 @@ dependencies = [
"enumflags2",
"serde",
"static_assertions",
"zbus",
"zbus 5.19.0",
"zbus-lockstep",
"zbus-lockstep-macros",
"zbus_names",
"zvariant",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
@@ -413,7 +435,7 @@ checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
dependencies = [
"atspi-common",
"serde",
"zbus",
"zbus 5.19.0",
]
[[package]]
@@ -467,6 +489,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block2"
version = "0.5.1"
@@ -533,6 +564,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -595,6 +632,15 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.6.0"
@@ -652,6 +698,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -821,6 +877,35 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "dbus"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
dependencies = [
"libc",
"libdbus-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "dbus-secret-service"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
dependencies = [
"aes",
"block-padding",
"cbc",
"dbus",
"fastrand",
"hkdf",
"num",
"once_cell",
"sha2",
"zeroize",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
@@ -853,6 +938,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1322,6 +1408,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -1335,8 +1427,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
@@ -1594,6 +1689,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -1764,6 +1877,16 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding",
"generic-array",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1878,6 +2001,23 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"dbus-secret-service",
"log",
"secret-service",
"security-framework 2.11.1",
"security-framework 3.7.0",
"windows-sys 0.60.2",
"zbus 4.4.0",
"zeroize",
]
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -1913,6 +2053,15 @@ version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libdbus-sys"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
dependencies = [
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.8.9"
@@ -2123,12 +2272,49 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
@@ -2138,6 +2324,36 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3098,7 +3314,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 3.7.0",
]
[[package]]
@@ -3125,7 +3341,7 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -3184,6 +3400,38 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
dependencies = [
"aes",
"cbc",
"futures-util",
"generic-array",
"hkdf",
"num",
"once_cell",
"rand",
"serde",
"sha2",
"zbus 4.4.0",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3282,6 +3530,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -3445,7 +3704,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -3453,12 +3712,14 @@ dependencies = [
"embed-resource",
"getrandom 0.3.4",
"hidapi",
"keyring",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 0.4.0",
"quick-xml",
"raw-window-handle",
"rfd",
"ron",
"rustls",
@@ -3466,28 +3727,40 @@ dependencies = [
"solidcraft-engine",
"solidcraft-ui-egui",
"ureq",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "solidcraft-cli"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"serde_json",
"solidcraft-engine",
"solidcraft-mcp",
]
[[package]]
name = "solidcraft-derive"
version = "0.13.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "solidcraft-doc"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"log",
"serde",
"serde_json",
"sha2",
"solidcraft-derive",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-render",
"solidcraft-sketch",
"thiserror 2.0.21",
"web-time",
@@ -3495,7 +3768,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"log",
"serde",
@@ -3511,7 +3784,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"serde",
"serde_json",
@@ -3520,7 +3793,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"log",
"naga",
@@ -3533,7 +3806,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"quick-xml",
"serde",
@@ -3548,7 +3821,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"log",
"rayon",
@@ -3566,7 +3839,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -3574,17 +3847,20 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"image",
"moxcms",
"png",
"serde",
"serde_json",
"sha2",
"solidcraft-geom",
]
[[package]]
name = "solidcraft-sketch"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"serde",
"serde_json",
@@ -3595,7 +3871,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"egui",
"egui-wgpu",
@@ -3616,7 +3892,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"eframe",
"egui",
@@ -3951,6 +4227,7 @@ dependencies = [
"spade",
"truck-base",
"truck-geometry",
"truck-modeling",
"truck-polymesh",
"truck-topology",
]
@@ -5067,6 +5344,16 @@ version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
[[package]]
name = "xdg-home"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "xkbcommon-dl"
version = "0.4.2"
@@ -5094,7 +5381,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.11.1"
version = "0.13.0"
dependencies = [
"serde_json",
"sha2",
@@ -5123,6 +5410,44 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zbus"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-sink",
"futures-util",
"hex",
"nix",
"ordered-stream",
"rand",
"serde",
"serde_repr",
"sha1",
"static_assertions",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
"xdg-home",
"zbus_macros 4.4.0",
"zbus_names 3.0.0",
"zvariant 4.2.0",
]
[[package]]
name = "zbus"
version = "5.19.0"
@@ -5153,9 +5478,9 @@ dependencies = [
"uuid",
"windows-sys 0.61.2",
"winnow",
"zbus_macros",
"zbus_names",
"zvariant",
"zbus_macros 5.19.0",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
@@ -5165,7 +5490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
dependencies = [
"zbus_xml",
"zvariant",
"zvariant 5.15.0",
]
[[package]]
@@ -5179,7 +5504,20 @@ dependencies = [
"syn 2.0.119",
"zbus-lockstep",
"zbus_xml",
"zvariant",
"zvariant 5.15.0",
]
[[package]]
name = "zbus_macros"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -5192,9 +5530,20 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"zbus_names",
"zvariant",
"zvariant_utils",
"zbus_names 4.3.4",
"zvariant 5.15.0",
"zvariant_utils 4.2.0",
]
[[package]]
name = "zbus_names"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
dependencies = [
"serde",
"static_assertions",
"zvariant 4.2.0",
]
[[package]]
@@ -5205,7 +5554,7 @@ checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow",
"zvariant",
"zvariant 5.15.0",
]
[[package]]
@@ -5216,8 +5565,8 @@ checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
dependencies = [
"serde",
"winnow",
"zbus_names",
"zvariant",
"zbus_names 4.3.4",
"zvariant 5.15.0",
]
[[package]]
@@ -5275,6 +5624,20 @@ name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
@@ -5362,6 +5725,19 @@ dependencies = [
"zune-core",
]
[[package]]
name = "zvariant"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
dependencies = [
"endi",
"enumflags2",
"serde",
"static_assertions",
"zvariant_derive 4.2.0",
]
[[package]]
name = "zvariant"
version = "5.15.0"
@@ -5373,8 +5749,21 @@ dependencies = [
"serde",
"winnow",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
"zvariant_derive 5.15.0",
"zvariant_utils 4.2.0",
]
[[package]]
name = "zvariant_derive"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.119",
"zvariant_utils 2.1.0",
]
[[package]]
@@ -5387,7 +5776,18 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"zvariant_utils",
"zvariant_utils 4.2.0",
]
[[package]]
name = "zvariant_utils"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
+8 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.11.1"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -24,13 +24,19 @@ solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
# foundation
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
# derive macros (crates/derive); already in the tree through serde_derive
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
sha2 = "0.10"
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
semver = "1"
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
@@ -53,6 +59,7 @@ ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
# images
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
png = "0.18.1"
# ui
egui = "0.36"
+8 -1
View File
@@ -21,6 +21,8 @@ model through the built-in MCP server.
- **Windows:** download and run the [installer][windows-msi].
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
[portable zip][windows-portable] that runs without installing.
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
`winget install bherbruck.SolidCraft`.
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
(Apple silicon and Intel).
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
@@ -128,12 +130,16 @@ rebuild with no errors, replayed here at 2× speed.
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
assemblies with joints as URDF for robot simulators (docs/urdf.md);
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
See [opaque surfaces](docs/render-opaque-pbr.md).
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
@@ -161,6 +167,7 @@ cargo run --release -p solidcraft -- --sample # desktop app with a samp
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
+87 -5
View File
@@ -21,6 +21,8 @@ offsets and plastic face placements, XZ (+Y normal) with a v6 migration preservi
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
so edge-touching assembly neighbours stay separate regardless of body order (#226).
In scope, in priority order:
1. SOLID and SKETCH (now).
@@ -51,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
@@ -59,6 +61,18 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| 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 |
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.
## Interactive modelling
- 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.
## Physical material data
- Physical material card validation (#923/#922) provides typed SI mechanical and
@@ -89,9 +103,47 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## Render foundation
- 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.
It does not enable PBR rendering or change stored appearances. See
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;
@@ -100,8 +152,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
- 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.
Existing snapshot and viewport routes remain unchanged. The Render workspace, HDRI/PBR,
render jobs and optional engines remain future work; see [the contract](docs/render-backends.md)
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
@@ -170,6 +222,13 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
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, …) 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. 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,
@@ -247,6 +306,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
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 edges (the blends meet in mitres), runs of
@@ -264,7 +325,8 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
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.
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;
@@ -298,6 +360,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
walls through denser stations where the path has corners or the profile has holes.
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
@@ -443,6 +508,15 @@ editing. Construction planes and points resolve drawn 3D wires without applying
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
@@ -488,6 +562,14 @@ and reference-object placement remain separate work, as do loft rails and center
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.
+1000 -259
View File
File diff suppressed because it is too large Load Diff
+171 -47
View File
@@ -1,12 +1,12 @@
//! SolidCraft headless command line.
//!
//! ```text
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
@@ -24,6 +24,7 @@ mod crashes;
mod fuzz;
mod oracle;
mod recipe;
mod render;
mod spatial;
use std::process::ExitCode;
@@ -33,18 +34,27 @@ use serde_json::{Value, json};
mod app_icon;
use solidcraft_engine::Session;
use solidcraft_engine::render::{StandardView, render_png};
use solidcraft_engine::render::render_png;
use solidcraft_engine::view::Guides;
const USAGE: &str = "usage:
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--allow-errors]
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect; or --in FILE)
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)
(--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
solidcraft-cli recipe <recipe.json>
@@ -63,6 +73,7 @@ fn main() -> ExitCode {
Some("eval" | "inspect") => cmd_eval(&args[1..]),
Some("script") => cmd_script(&args[1..]),
Some("snapshot") => cmd_snapshot(&args[1..]),
Some("render") => render::run(&args[1..]),
Some("exec") => cmd_exec(&args[1..]),
Some("commands") => {
use std::io::Write as _;
@@ -165,7 +176,7 @@ fn positional(args: &[String]) -> Option<&String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if a.starts_with("--") {
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors") { 1 } else { 2 };
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors" | "--no-grid" | "--no-axes") { 1 } else { 2 };
continue;
}
return Some(a);
@@ -173,32 +184,52 @@ fn positional(args: &[String]) -> Option<&String> {
None
}
/// `--json-out FILE`: where the JSON a command prints goes instead of stdout.
fn json_out(args: &[String]) -> Result<Option<&str>, String> {
if !args.iter().any(|a| a == "--json-out") {
return Ok(None);
}
match flag(args, "--json-out") {
Some(file) if !file.starts_with("--") => Ok(Some(file)),
_ => Err("--json-out needs a FILE".to_string()),
}
}
/// `v` as indented JSON with a final newline: on stdout, or in `file`. The file is plain UTF-8
/// with no byte-order mark, whatever the platform's shell does to redirected output (Windows
/// PowerShell's `>` writes UTF-16, or UTF-8 with a BOM), so strict JSON readers take it.
fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
match file {
Some(file) => {
let mut text = pretty(v);
text.push('\n');
std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
}
None => {
println!("{}", pretty(v));
Ok(())
}
}
}
fn cmd_run(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
return Err(USAGE.to_string());
}
// `--in` starts from a design or STEP file; the positional script then runs on top of it.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
let results = if args.iter().any(|a| a == "--results") {
let (mut s, results) = if args.iter().any(|a| a == "--results") {
// Collect every command's result (scripts only), then process requested files too.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
let v = read_json(path.ok_or("--results needs a script")?)?;
let r = s.run_script(&v).map_err(|e| e.to_string())?;
Some(Value::Array(r))
(s, Some(Value::Array(r)))
} else {
match (base, path) {
(Some(_), Some(p)) => {
let v = read_json(p)?;
s.run_script(&v).map_err(|e| e.to_string())?;
}
(None, Some(p)) => s = load(p)?,
_ => {}
}
None
(open(base, path)?, None)
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
@@ -213,10 +244,45 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if let Some(results) = results {
println!("{}", pretty(&results));
} else if !args.iter().any(|a| a == "--quiet") {
print_json(&results, json_out)?;
} else if json_out.is_some() || !args.iter().any(|a| a == "--quiet") {
// `--quiet` silences stdout; a file that was asked for is still written.
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
println!("{}", pretty(&m));
print_json(&m, json_out)?;
}
Ok(())
}
/// The session `run` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone.
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
match (base, path) {
(Some(b), Some(p)) => {
let mut s = load(b)?;
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
Ok(s)
}
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(None, None) => Err(USAGE.to_string()),
}
}
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
/// command's option, would otherwise be ignored without a word.
fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -> Result<(), String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if valued.contains(&a.as_str()) {
match args.get(i + 1) {
Some(v) if !v.starts_with("--") => i += 2,
_ => return Err(format!("{cmd}: {a} needs a value")),
}
} else if a.starts_with("--") && !switches.contains(&a.as_str()) {
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
} else {
i += 1;
}
}
Ok(())
}
@@ -235,6 +301,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> {
}
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let mut s = load(path)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
@@ -247,8 +314,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
"params": d["params"],
"sketches": d["sketches"],
});
println!("{}", pretty(&out));
Ok(())
print_json(&out, json_out)
}
/// A design's history as a command script (`timeline.export_script`): printed, or written to
@@ -269,16 +335,35 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
}
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or_else(|| USAGE.to_string())?;
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
let s = load(path)?;
let mut cam = solidcraft_engine::view::home_camera(&s);
if let Some(v) = flag(args, "--view") {
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
if positional(args).is_none() && flag(args, "--in").is_none() {
return Err(USAGE.to_string());
}
let scene = solidcraft_engine::view::scene(&s, &cam);
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let size = |name: &str, default: usize| match flag(args, name) {
None => Ok(default),
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let mut s = open(flag(args, "--in"), positional(args))?;
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
// standard view, a view saved in the design, or an explicit camera. A script that applies a
// view itself sets the camera too; without any, the iso view framing the model.
match (flag(args, "--view"), flag(args, "--camera")) {
(Some(_), Some(_)) => return Err("snapshot: give --view or --camera, not both".into()),
(Some(v), None) => {
let hint = "standard views: iso, front, back, top, bottom, left, right";
s.execute("view.apply", &json!({"view": v})).map_err(|e| format!("--view {v}: {e} ({hint}; or a view saved with view.save)"))?;
}
(None, Some(c)) => {
let camera: Value = serde_json::from_str(c).map_err(|e| format!("--camera: not JSON: {e}"))?;
s.execute("view.apply", &json!({"camera": camera})).map_err(|e| format!("--camera: {e}"))?;
}
(None, None) => {}
}
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
@@ -359,13 +444,20 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
}
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect_with_token(addr, flag(args, "--token")).map_err(|e| {
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
format!(
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
`solidcraft --control {port}` (or SOLIDCRAFT_CONTROL_PORT={port}) and retry"
)
})?),
(Some(addr), None) => {
let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok()).filter(|p| *p != 0) else {
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
};
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
if let Err(e) = remote.try_connect() {
eprintln!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
);
}
Box::new(remote)
}
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
};
@@ -465,5 +557,37 @@ pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
st.warm_meshes(|| true);
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
// evaluation and the plans of the bodies it changed.
let flag = |name: &str| args.iter().position(|a| a == name);
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
let plans = |st: &solidcraft_engine::doc::ModelState| {
let t = std::time::Instant::now();
solidcraft_engine::kernel::for_each_on_threads(&st.bodies, 16, |b| {
let _ = b.plan_mesh_at(q);
});
let triangles: usize = st.bodies.iter().filter_map(|b| b.plan_mesh_at(q)).map(|m| m.triangles.len()).sum();
(t.elapsed().as_secs_f64() * 1000.0, triangles)
};
let (ms, triangles) = plans(&st);
println!("{}", json!({"step": "display", "quality": q.id(), "ms": ms, "triangles": triangles}));
if let Some(i) = flag("--edit") {
let (Some(name), Some(expr)) = (args.get(i + 1), args.get(i + 2)) else { return Err("--edit NAME EXPR".into()) };
let mut s = s;
let t = std::time::Instant::now();
s.execute("parameters.change", &json!({"name": name, "expression": expr})).map_err(|e| e.to_string())?;
let eval_ms = t.elapsed().as_secs_f64() * 1000.0;
let st = s.world_state();
let t = std::time::Instant::now();
st.warm_meshes(|| true);
let mesh_ms = t.elapsed().as_secs_f64() * 1000.0;
let (ms, triangles) = plans(&st);
println!(
"{}",
json!({"step": "edit", "quality": q.id(), "evaluate_ms": eval_ms, "mesh_ms": mesh_ms, "display_ms": ms, "triangles": triangles})
);
}
Ok(())
}
+1 -1
View File
@@ -92,7 +92,7 @@ fn extent(f: &Value, p: &mut Value) {
"symmetric" => {
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
p["direction"] = json!("symmetric");
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
p["distance"] = if full { dist } else { json!(format!("({}) * 2", expr_text(&dist))) };
}
"two_sides" => {
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
+79
View File
@@ -0,0 +1,79 @@
//! Complete headless image-job workflow through the same engine commands as the UI/MCP.
use serde_json::json;
use solidcraft_engine::{
doc::render_settings::RenderSetup,
render::job::{CpuHost, JobId, Phase},
};
pub fn run(args: &[String]) -> Result<(), String> {
let input = args.first().filter(|p| !p.starts_with('-')).ok_or("render requires an input design or script")?;
let mut out = None;
let mut setup = None;
let mut width = None;
let mut height = None;
let mut transparent = false;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
while let Some(arg) = args.get(i) {
match arg.as_str() {
"--transparent" => transparent = true,
"--overwrite" => overwrite = true,
"--out" | "--setup" | "--width" | "--height" | "--timeout" => {
i += 1;
let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?;
match arg.as_str() {
"--out" => out = Some(value.clone()),
"--setup" => setup = Some(value.clone()),
"--width" => width = Some(value.parse::<u32>().map_err(|_| "invalid width")?),
"--height" => height = Some(value.parse::<u32>().map_err(|_| "invalid height")?),
_ => timeout = value.parse::<u64>().map_err(|_| "invalid timeout")?,
}
}
_ => return Err(format!("unknown render option: {arg}")),
}
i += 1;
}
if !(1..=3600).contains(&timeout) {
return Err("render timeout must be 1..3600 seconds".into());
}
let out = out.ok_or("render requires --out FILE.png")?;
let mut session = crate::load(input)?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
let mut settings = if let Some(name) = setup {
session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")?
} else {
let mut settings = RenderSetup::default();
if let Some(camera) = session.camera {
settings.camera = camera;
} else {
settings.camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds);
}
settings
};
if let Some(width) = width {
settings.width = width;
}
if let Some(height) = height {
settings.height = height;
}
let params = json!({"settings":settings,"transparent_background":transparent});
let preflight = session.execute("render.preflight", &params).map_err(|e| e.to_string())?;
let started = session.execute("render.start", &params).map_err(|e| e.to_string())?;
let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?;
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
let status = match handle.wait_timeout(std::time::Duration::from_secs(timeout)) {
Ok(status) => status,
Err(e) => {
let _ = session.execute("render.cancel", &json!({"job":id}));
return Err(e.to_string());
}
};
if status.progress.phase != Phase::Completed {
return Err(status.error.map_or_else(|| "render cancelled".into(), |e| e.to_string()));
}
let exported = session.execute("render.export", &json!({"job":id,"path":out,"overwrite":overwrite})).map_err(|e| e.to_string())?;
println!("{}", crate::pretty(&json!({"preflight":preflight,"export":exported})));
Ok(())
}
+403 -49
View File
@@ -13,8 +13,10 @@
//! fails the test (a regression). Newly passing actions are reported; record them with
//! `cargo xtask action-matrix --bless` (or `ACTION_MATRIX_BLESS=1` on [`action_matrix_all`]).
//! Every CI run checks a fixed sample ([`action_matrix_sample`], under a minute); the full matrix
//! is four ignored tests (`action_matrix_full_N`) that the nightly "Action matrix" job runs side by
//! side. See docs/action-matrix.md.
//! is the ignored [`action_matrix_shard`], which the nightly "Action matrix" jobs run one shard
//! each (`ACTION_MATRIX_SHARD=k/n`), cut by what each design costs (`tests/action_matrix/costs.json`).
//! [`action_matrix_determinism`] runs a sample twice and lists any action whose outcome changed.
//! See docs/action-matrix.md.
//!
//! Each design runs in a child process (this test binary, [`action_matrix_worker`]) so an action
//! that hangs or aborts is recorded as such and the rest of the design still runs.
@@ -33,6 +35,10 @@ use solidcraft_engine::geom::Vec3;
/// One in this many actions runs on pull requests (by a stable hash of its key).
const PR_SAMPLE: u64 = 40;
/// One in this many actions runs twice in the nightly determinism check, on the designs whose
/// whole matrix takes at most [`DETERMINISM_MAX_SECS`] on a runner (most of the corpus).
const DETERMINISM_SAMPLE: u64 = 8;
const DETERMINISM_MAX_SECS: f64 = 300.0;
/// Designs that take seconds to build: the full matrix only.
const SLOW: [&str; 7] = [
"oracle/41-tube-radial-holes",
@@ -623,17 +629,25 @@ fn action_matrix_worker() {
out.flush().unwrap();
};
let Some((_, path)) = designs().into_iter().find(|(n, _)| *n == design) else { panic!("no design {design}") };
// A slice of a heavy design (`j/n`): the actions whose name hashes to `j` modulo `n` (by
// index, slices would take whole kinds of action when n shares a factor with the number of
// actions per entity, and a slice of only fillets runs three times as long); the first
// slice records the load.
let slice: Option<(usize, usize)> = std::env::var("ACTION_MATRIX_SLICE").ok().and_then(|v| parse_part(&v));
let first = slice.is_none_or(|(j, _)| j == 0);
let t0 = Instant::now();
let s = match load(&path) {
Ok(s) => s,
Err(e) => {
emit(outcome(&design, "design", "load", &Err(("load".into(), e)), t0.elapsed().as_millis()));
if first {
emit(outcome(&design, "design", "load", &Err(("load".into(), e)), t0.elapsed().as_millis()));
}
emit(json!({"done": true}));
return;
}
};
let before = errors(&s);
if from == 0 {
if from == 0 && first {
let r = if before.is_empty() { Ok(()) } else { Err(("timeline_error".into(), before.values().cloned().collect::<Vec<_>>().join("; "))) };
emit(outcome(&design, "design", "load", &r, t0.elapsed().as_millis()));
}
@@ -647,6 +661,9 @@ fn action_matrix_worker() {
if only.as_ref().is_some_and(|o| !o.iter().any(|x| x == a.action)) {
continue;
}
if slice.is_some_and(|(j, n)| stable_hash(&format!("{} {}", a.entity, a.action)) % n as u64 != j as u64) {
continue;
}
// Whole families of pieces are sampled together (see [`family`]).
let k = key(&design, &family(&a.entity), a.action);
if !stable_hash(&k).is_multiple_of(sample) {
@@ -708,46 +725,155 @@ fn action_matrix_worker() {
emit(json!({"done": true, "actions": list.len()}));
}
/// Run the actions (one in `sample`) of every design in part `part` of `parts`, in child
/// processes, `jobs` at a time.
fn run_matrix(sample: u64, part: usize, parts: usize) -> Vec<Value> {
/// `"k/n"` (1-based `k`) as `(k - 1, n)`.
fn parse_part(v: &str) -> Option<(usize, usize)> {
let (k, n) = v.split_once('/')?;
let (k, n): (usize, usize) = (k.trim().parse().ok()?, n.trim().parse().ok()?);
(k >= 1 && k <= n).then_some((k - 1, n))
}
/// What one design costs on a CI runner (`tests/action_matrix/costs.json`, measured by the
/// nightly shards): the whole run and the load before its first action, in seconds.
#[derive(Clone, Copy, Debug)]
struct Cost {
secs: f64,
load: f64,
}
fn costs_path() -> PathBuf {
root().join("tests/action_matrix/costs.json")
}
fn read_costs() -> BTreeMap<String, Cost> {
let Some(v) = std::fs::read(costs_path()).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else { return BTreeMap::new() };
v["designs"]
.as_object()
.into_iter()
.flatten()
.map(|(d, c)| (d.clone(), Cost { secs: c["secs"].as_f64().unwrap_or(0.0), load: c["load"].as_f64().unwrap_or(0.0) }))
.collect()
}
/// A design new to the corpus (not in costs.json yet) is planned as this.
const UNKNOWN_COST: Cost = Cost { secs: 120.0, load: 5.0 };
/// A design that takes longer than this on a runner is split into slices of about this much, or
/// five loads if that is more (each slice loads the design again), so no shard waits on one design.
const SLICE_SECS: f64 = 600.0;
/// Designs that take longer than this to load on a runner stay out of the pull request sample.
const SAMPLE_MAX_LOAD_SECS: f64 = 8.0;
/// A piece of work: one design, or a slice `(j, n)` of a heavy one, and what it should cost.
#[derive(Clone, Debug)]
struct Unit {
design: String,
slice: Option<(usize, usize)>,
cost: f64,
}
impl Unit {
fn name(&self) -> String {
match self.slice {
Some((j, n)) => format!("{} [{}/{n}]", self.design, j + 1),
None => self.design.clone(),
}
}
}
/// The whole matrix split into `parts` shards of about equal cost: heavy designs in slices, then
/// the largest pieces first, each to the shard with the least so far. The same corpus and
/// costs.json always give the same shards.
fn plan(designs: &[String], costs: &BTreeMap<String, Cost>, parts: usize) -> Vec<Vec<Unit>> {
let mut units = Vec::new();
for d in designs {
let c = costs.get(d).copied().unwrap_or(UNKNOWN_COST);
let n = ((c.secs / SLICE_SECS.max(5.0 * c.load)).ceil() as usize).max(1);
if n == 1 {
units.push(Unit { design: d.clone(), slice: None, cost: c.secs });
} else {
let each = c.load + (c.secs - c.load).max(0.0) / n as f64;
units.extend((0..n).map(|j| Unit { design: d.clone(), slice: Some((j, n)), cost: each }));
}
}
units.sort_by(|a, b| b.cost.total_cmp(&a.cost).then_with(|| a.name().cmp(&b.name())));
let mut shards: Vec<(f64, Vec<Unit>)> = vec![(0.0, Vec::new()); parts];
for u in units {
let i = (0..parts).min_by(|&a, &b| shards[a].0.total_cmp(&shards[b].0)).unwrap_or(0);
shards[i].0 += u.cost;
shards[i].1.push(u);
}
shards.into_iter().map(|(_, v)| v).collect()
}
/// Every design's name, with `ACTION_MATRIX_FILTER` applied.
fn design_names() -> Vec<String> {
let designs = designs();
assert!(designs.len() > 80, "only {} designs", designs.len());
let dir = std::env::temp_dir().join(format!("action-matrix-{}-{part}", std::process::id()));
let filter = std::env::var("ACTION_MATRIX_FILTER").ok();
designs.into_iter().map(|(n, _)| n).filter(|n| filter.as_deref().is_none_or(|f| n.contains(f))).collect()
}
/// Run the actions (one in `sample`) of these units in child processes, `jobs` at a time.
///
/// `ACTION_MATRIX_WORKDIR` keeps each design's raw outcomes there (one JSON line each, written as
/// they come, so a run cut short still shows what it did) and `ACTION_MATRIX_TIMINGS` appends
/// each unit's time to that file (JSON lines).
fn run_units(sample: u64, queue: &[Unit]) -> Vec<Value> {
let keep = std::env::var_os("ACTION_MATRIX_WORKDIR").map(PathBuf::from);
static RUN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("action-matrix-{}-{run}", std::process::id())));
std::fs::create_dir_all(&dir).unwrap();
let timings = std::env::var_os("ACTION_MATRIX_TIMINGS").map(PathBuf::from);
let jobs: usize = std::env::var("ACTION_MATRIX_JOBS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| std::thread::available_parallelism().map_or(2, |n| n.get()))
.max(1);
let filter = std::env::var("ACTION_MATRIX_FILTER").ok();
let queue: Vec<(String, PathBuf)> = designs
.into_iter()
.enumerate()
.filter(|(i, (n, _))| i % parts == part && filter.as_deref().is_none_or(|f| n.contains(f)) && (sample == 1 || !SLOW.contains(&n.as_str())))
.map(|(_, d)| d)
.collect();
let next = std::sync::atomic::AtomicUsize::new(0);
let results = std::sync::Mutex::new(Vec::new());
std::thread::scope(|scope| {
for _ in 0..jobs {
scope.spawn(|| {
while let Some((name, _)) = queue.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let out = dir.join(format!("{}.jsonl", name.replace('/', "__")));
let lines = run_design(name, &out, sample);
while let Some(u) = queue.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let file = u.name().replace('/', "__").replace([' ', '[', ']'], "_");
let out = dir.join(format!("{file}.r{run}.jsonl"));
let t = Instant::now();
let lines = run_design(&u.design, &out, sample, u.slice);
let secs = t.elapsed().as_secs_f64();
let load = lines.iter().find(|o| o["entity"] == "design" && o["action"] == "load").and_then(|o| o["ms"].as_f64()).unwrap_or(0.0) / 1000.0;
if let Some(p) = &timings {
let line = json!({"design": u.design, "slice": u.slice.map(|(j, n)| format!("{}/{n}", j + 1)), "secs": round3(secs), "load": round3(load), "actions": lines.len()});
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(p).unwrap();
writeln!(f, "{line}").unwrap();
}
eprintln!("action matrix: {} took {secs:.1} s ({} outcomes, load {load:.1} s)", u.name(), lines.len());
results.lock().unwrap().extend(lines);
}
});
}
});
let _ = std::fs::remove_dir_all(&dir);
if keep.is_none() {
let _ = std::fs::remove_dir_all(&dir);
}
let mut v = results.into_inner().unwrap();
v.sort_by_key(|o| key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or("")));
v
}
/// Run the actions (one in `sample`) of every design (the pull request sample leaves out the
/// slow-loading ones).
fn run_matrix(sample: u64) -> Vec<Value> {
let costs = read_costs();
let queue: Vec<Unit> = design_names()
.into_iter()
.filter(|n| sample == 1 || !(SLOW.contains(&n.as_str()) || costs.get(n).is_some_and(|c| c.load > SAMPLE_MAX_LOAD_SECS)))
.map(|design| Unit { design, slice: None, cost: 0.0 })
.collect();
run_units(sample, &queue)
}
/// One design in child processes, restarting after an action that hangs or aborts.
fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
fn run_design(design: &str, out: &Path, sample: u64, slice: Option<(usize, usize)>) -> Vec<Value> {
let _ = std::fs::remove_file(out);
let mut from = 0usize;
let mut results = Vec::new();
@@ -759,6 +885,7 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
.env("ACTION_MATRIX_OUT", out)
.env("ACTION_MATRIX_FROM", from.to_string())
.env("ACTION_MATRIX_SAMPLE", sample.to_string())
.env("ACTION_MATRIX_SLICE", slice.map(|(j, n)| format!("{}/{n}", j + 1)).unwrap_or_default())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
@@ -796,6 +923,9 @@ fn run_design(design: &str, out: &Path, sample: u64) -> Vec<Value> {
}
// It stopped inside action `last_start`: record it and go on after it.
let Some(i) = last_start else {
if slice.is_some_and(|(j, _)| j > 0) {
break;
}
extra.push(
json!({"design": design, "entity": "design", "action": "load", "ok": false, "kind": if timed_out { "timeout" } else { "crash" },
"message": format!("the design did not load ({status:?})")}),
@@ -863,9 +993,12 @@ fn write_baseline(results: &[Value]) {
m.entry(design).or_default().entry(entity).or_default().extend(actions.as_str().unwrap_or("").split_whitespace().map(str::to_string));
}
}
// A timeout of an action that passed keeps it passing (see [`ratchet`]).
let base = read_baseline();
for o in results {
let (design, entity, action) = (o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
let mark = if o["ok"].as_bool() == Some(true) { "" } else { "!" };
let kept = o["kind"] == "timeout" && base.get(&key(design, entity, action)) == Some(&true);
let mark = if o["ok"].as_bool() == Some(true) || kept { "" } else { "!" };
m.entry(design).or_default().entry(entity).or_default().push(format!("{mark}{action}"));
}
// One entity per line, sorted: small diffs when a few actions change.
@@ -894,10 +1027,18 @@ fn ratchet(results: &[Value], full: bool) {
// baseline, over the same names this run tried, and in this run.
let mut fam: BTreeMap<String, (usize, usize, Vec<String>, Vec<String>)> = BTreeMap::new();
let mut fresh = 0usize;
// An action that passed in the baseline and now runs past ACTION_TIMEOUT is listed but not
// judged: on a busy runner the slowest actions (40-80 s on oracle 41's tube) land either side
// of the limit from one night to the next. An error or a crash is judged as usual.
let mut slow = Vec::new();
for o in results {
let (design, entity, action) = (o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
let k = key(design, entity, action);
let ok = o["ok"].as_bool() == Some(true);
if o["kind"] == "timeout" && base.get(&k) == Some(&true) {
slow.push(k);
continue;
}
let f = fam.entry(key(design, &family(entity), action)).or_default();
match base.get(&k) {
Some(was) => {
@@ -934,6 +1075,14 @@ fn ratchet(results: &[Value], full: bool) {
eprintln!("baseline written: {}", baseline_path().display());
return;
}
if !slow.is_empty() {
eprintln!(
"{} actions that pass in the baseline ran past the {} s limit this time (not judged):\n{}",
slow.len(),
ACTION_TIMEOUT.as_secs(),
slow.join("\n")
);
}
if !fixed.is_empty() || fresh > 0 {
eprintln!(
"{} actions pass now that failed in the baseline, and {fresh} are not in it: record them with `cargo xtask action-matrix --bless`:\n{}",
@@ -952,51 +1101,156 @@ fn ratchet(results: &[Value], full: bool) {
/// Pull requests: a fixed sample of the matrix (one action in [`PR_SAMPLE`]) against the ratchet.
#[test]
fn action_matrix_sample() {
let results = run_matrix(PR_SAMPLE, 0, 1);
let results = run_matrix(PR_SAMPLE);
assert!(results.len() > 300, "only {} actions ran", results.len());
ratchet(&results, false);
}
/// The whole matrix against the ratchet, in four parts that the nightly CI job runs side by side
/// (`--run-ignored only`).
fn full_part(part: usize) {
let results = run_matrix(1, part, 4);
/// One shard of the whole matrix against the ratchet: `ACTION_MATRIX_SHARD=k/n` (the nightly
/// "Action matrix" CI jobs run the n shards side by side, `--run-ignored only`). Shards are cut by
/// what each design costs (tests/action_matrix/costs.json), see [`plan`].
/// `ACTION_MATRIX_SHARD_LIST=1` only prints the plan.
#[test]
#[ignore = "heavy: the nightly Action matrix CI jobs run it, one shard each"]
fn action_matrix_shard() {
let Some((k, n)) = std::env::var("ACTION_MATRIX_SHARD").ok().and_then(|v| parse_part(&v)) else { panic!("set ACTION_MATRIX_SHARD=k/n") };
let shards = plan(&design_names(), &read_costs(), n);
for (i, sh) in shards.iter().enumerate() {
let total: f64 = sh.iter().map(|u| u.cost).sum();
eprintln!("shard {}/{n}: {} pieces, about {:.0} s of work{}", i + 1, sh.len(), total, if i == k { " (this one)" } else { "" });
}
eprintln!("this shard: {}", shards[k].iter().map(Unit::name).collect::<Vec<_>>().join(", "));
if std::env::var_os("ACTION_MATRIX_SHARD_LIST").is_some() {
return;
}
let results = run_units(1, &shards[k]);
ratchet(&results, false);
}
/// Determinism: a sample of the matrix (one family in [`DETERMINISM_SAMPLE`], the heaviest
/// designs left out) run twice must give the same outcome for every action. Lists each action
/// whose outcome changed between the runs.
#[test]
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
fn action_matrix_full_1() {
full_part(0);
}
#[test]
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
fn action_matrix_full_2() {
full_part(1);
}
#[test]
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
fn action_matrix_full_3() {
full_part(2);
}
#[test]
#[ignore = "heavy: the nightly Action matrix CI job runs it"]
fn action_matrix_full_4() {
full_part(3);
#[ignore = "heavy: the nightly Action matrix determinism CI job runs it"]
fn action_matrix_determinism() {
let outcomes = |v: Vec<Value>| -> BTreeMap<String, String> {
v.into_iter()
.map(|o| {
let k = key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or(""));
let r = if o["ok"].as_bool() == Some(true) {
"pass".to_string()
} else {
format!("{}: {}", o["kind"].as_str().unwrap_or(""), o["message"].as_str().unwrap_or(""))
};
(k, r)
})
.collect()
};
let costs = read_costs();
let queue: Vec<Unit> = design_names()
.into_iter()
.filter(|n| costs.get(n).copied().unwrap_or(UNKNOWN_COST).secs <= DETERMINISM_MAX_SECS)
.map(|design| Unit { design, slice: None, cost: 0.0 })
.collect();
let a = outcomes(run_units(DETERMINISM_SAMPLE, &queue));
let b = outcomes(run_units(DETERMINISM_SAMPLE, &queue));
let keys: BTreeSet<&String> = a.keys().chain(b.keys()).collect();
let mut changed = Vec::new();
// Pieces that share a name can swap numbers between runs (#569): compare passes per family
// too, and report a family whose pass count differs.
let mut fam: BTreeMap<String, (usize, usize)> = BTreeMap::new();
for k in &keys {
let (x, y) = (a.get(*k).map_or("missing", String::as_str), b.get(*k).map_or("missing", String::as_str));
let mut parts = k.splitn(3, " :: ");
let (d, e, act) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""), parts.next().unwrap_or(""));
let f = fam.entry(key(d, &family(e), act)).or_default();
f.0 += usize::from(x == "pass");
f.1 += usize::from(y == "pass");
if x != y {
changed.push(format!("{k}\n first: {x}\n second: {y}"));
}
}
let families: Vec<String> = fam.into_iter().filter(|(_, (x, y))| x != y).map(|(k, (x, y))| format!("{k}: {x} then {y} pass")).collect();
eprintln!(
"action matrix determinism: {} actions, {} changed between two runs, {} families pass a different number of times",
keys.len(),
changed.len(),
families.len()
);
for c in &changed {
eprintln!(" {c}");
}
assert!(
families.is_empty(),
"{} families of actions pass a different number of times in two runs of the same build:\n{}",
families.len(),
families.join("\n")
);
}
/// The whole matrix at once (`cargo xtask action-matrix`): `ACTION_MATRIX_BLESS=1` rewrites the
/// baseline; `ACTION_MATRIX_RESULTS=file.json` keeps every outcome with its message.
/// `ACTION_MATRIX_MERGE=DIR` runs nothing and takes the outcomes from the `results-*.json` files
/// in DIR instead (the nightly shards' artifacts), and the timings from its `timings-*.jsonl`
/// files, which `ACTION_MATRIX_BLESS` then also writes to tests/action_matrix/costs.json.
#[test]
#[ignore = "cargo xtask action-matrix runs it (CI runs it as the four action_matrix_full parts)"]
#[ignore = "cargo xtask action-matrix runs it (CI runs it as the action_matrix_shard jobs)"]
fn action_matrix_all() {
let results = run_matrix(1, 0, 1);
let Some(dir) = std::env::var_os("ACTION_MATRIX_MERGE").map(PathBuf::from) else {
let results = run_matrix(1);
ratchet(&results, true);
return;
};
let mut results: Vec<Value> = Vec::new();
let mut timings: Vec<Value> = Vec::new();
let mut files: Vec<PathBuf> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).collect();
files.sort();
for f in files {
let name = f.file_name().unwrap_or_default().to_string_lossy().to_string();
let text = std::fs::read_to_string(&f).unwrap_or_default();
if name.starts_with("results-") && name.ends_with(".json") {
results.extend(serde_json::from_str::<Vec<Value>>(&text).unwrap_or_else(|e| panic!("{name}: {e}")));
} else if name.starts_with("timings-") {
timings.extend(text.lines().filter_map(|l| serde_json::from_str(l).ok()));
}
}
let ran: BTreeSet<&str> = results.iter().filter_map(|o| o["design"].as_str()).collect();
let missing: Vec<String> = design_names().into_iter().filter(|d| !ran.contains(d.as_str())).collect();
assert!(missing.is_empty(), "no results for {}", missing.join(", "));
results.sort_by_key(|o| key(o["design"].as_str().unwrap_or(""), o["entity"].as_str().unwrap_or(""), o["action"].as_str().unwrap_or("")));
if std::env::var_os("ACTION_MATRIX_BLESS").is_some() && !timings.is_empty() {
write_costs(&timings);
}
ratchet(&results, true);
}
/// tests/action_matrix/costs.json from the shards' timings: per design, its whole time (a sliced
/// design's slices added up, less the loads all but one of them repeat) and its load.
fn write_costs(timings: &[Value]) {
let mut m: BTreeMap<String, (f64, f64, usize)> = BTreeMap::new();
for t in timings {
let e = m.entry(t["design"].as_str().unwrap_or("").to_string()).or_default();
e.0 += t["secs"].as_f64().unwrap_or(0.0);
e.1 = e.1.max(t["load"].as_f64().unwrap_or(0.0));
e.2 += 1;
}
// Designs these timings don't cover keep their old costs.
for (d, c) in read_costs() {
m.entry(d).or_insert((c.secs, c.load, 1));
}
let mut s = String::from(
"{\n \"about\": \"What each action matrix design costs on a CI runner, in seconds (secs: the whole design, load: before its first action), for cutting the nightly shards (apps/solidcraft-cli/tests/action_matrix.rs, plan). Written with the baseline by cargo xtask action-matrix --bless --from DIR.\",\n \"designs\": {\n",
);
let n = m.len();
for (i, (d, (secs, load, pieces))) in m.into_iter().enumerate() {
let secs = (secs - load * pieces.saturating_sub(1) as f64).max(load);
s += &format!(" {}: {{\"secs\": {:.1}, \"load\": {:.1}}}{}\n", json!(d), secs, load, if i + 1 < n { "," } else { "" });
}
s += " }\n}\n";
std::fs::write(costs_path(), s).unwrap();
eprintln!("costs written: {}", costs_path().display());
}
// ---------------------------------------------------------------------------------------------
// The matrix's causes: one small reproduction per open issue, ignored until it is fixed (the
// issue says how many matrix actions it accounts for). Each runs commands as the desktop sends
@@ -1038,6 +1292,106 @@ fn a_fillet_or_chamfer_on_one_edge_ending_at_a_cylinder() {
}
}
// #1255: a round on a knuckle's cap circle (oracle/63's hinge), 0.73 wide on a ring of radius
// 4. The run past each end of the circle followed the end tangent so far that its sections
// wrapped round the ring and the blend was refused; it is shortened now while it still
// crosses the end face. Before #1101 a 0.13 mm station on the leaf's top edge kept the
// matrix's radius at 0.013, which never got that far.
#[test]
fn a_round_on_a_knuckle_cap_circle() {
for (cmd, size, removed) in [("solid.fillet", "radius", (2.4, 2.8)), ("solid.chamfer", "distance", (5.6, 6.3))] {
let mut s = load(&root().join("tests/oracle/63-hinge-knuckle/recipe.json")).unwrap();
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate(0.001).unwrap().measure().volume).sum::<f64>();
let before = volume(&s);
s.execute(cmd, &json!({"edges": [[40.0, 29.837_722, 6.449_490]], size: 0.728})).unwrap_or_else(|e| panic!("{cmd}: {e}"));
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{cmd}: {kind}: {e}"));
let st = s.model.state();
let body = &st.bodies.first().unwrap().body;
assert!(body.validity().is_empty(), "{cmd}: {:?}", body.validity());
let gone = before - volume(&s);
assert!(gone > removed.0 && gone < removed.1, "{cmd} took {gone} mm³");
}
}
// #1255: a hole 0.133 mm across on the 0.5 mm ledge a loft cut leaves in a pocket floor of a
// 900 mm slab. The Boolean needed a retry, and its guard meshed the drill as coarsely as the
// slab's size allows (0.4 mm, three times the drill's width): the mesh had no inside, the drill
// measured −30272 mm³ and the right cut was refused. Volumes are measured finer than each
// body's smallest radius now.
#[test]
fn a_hair_thin_hole_on_a_ledge_beside_a_loft_cut() {
let mut s = load(&root().join("examples/regressions/loft_cut_beside_round_hole.json")).unwrap();
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.005, true).unwrap().measure().volume).sum::<f64>();
let before = volume(&s);
s.execute("solid.hole", &json!({"diameter": 0.133, "position": [357.22, -150.993_333, 730.0]})).unwrap();
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
// The drill goes through the 4 mm ledge and on through the slab below: 690 to 730.
let gone = before - volume(&s);
let expected = std::f64::consts::PI * 0.0665 * 0.0665 * 40.0;
assert!((gone - expected).abs() < 0.2, "the hole took {gone} mm³, not {expected}");
}
// #1255: a 0.8 mm hole through the side wall of the plastic part's rest. Meshed at the
// tolerance the 200 mm drill sets, the part with its rounds, bosses and draft measured 6.8 mm³
// more after the cut than before; ten times finer it is 1.44 mm³ less, the drill's volume
// through the 2.9 mm wall. A volume out of bounds is measured again finer before it refuses.
#[test]
fn a_small_hole_through_the_wall_of_a_plastic_rest() {
let mut s = load(&root().join("apps/solidcraft-cli/tests/corpus/modern-plastic.solidcraft")).unwrap();
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.002, true).unwrap().measure().volume).sum::<f64>();
let before = volume(&s);
s.execute("solid.hole", &json!({"diameter": 0.8, "position": [63.069_841_5, 28.333_333, 4.0]})).unwrap();
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
let gone = before - volume(&s);
assert!(gone > 1.2 && gone < 1.7, "the hole took {gone} mm³");
}
// #1255: a hole square to a flange bent at 45°. The drill's cap, evaluated at that angle, lay
// 2.9e-11 mm off the plane it was made on against a bound of 2.2e-11, so the sheet didn't take
// the hole as a cut-out of its flat pattern and the cut was refused. The sheet keeps the hole
// and still unfolds and folds back.
#[test]
fn a_hole_square_to_a_45_degree_flange() {
let mut s = load(&root().join("tests/oracle/70-sm-flange-45/recipe.json")).unwrap();
let volume = |s: &Session| s.model.state().bodies.iter().map(|b| b.body.tessellate_with(0.002, true).unwrap().measure().volume).sum::<f64>();
let before = volume(&s);
s.execute("solid.hole", &json!({"diameter": 3.448, "position": [66.666_667, -4.511_845, 4.511_845]})).unwrap();
check(&s, 0, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
// Through the 2.5 mm flange.
let gone = before - volume(&s);
let expected = std::f64::consts::PI * 1.724 * 1.724 * 2.5;
assert!((gone - expected).abs() < 0.1, "the hole took {gone} mm³, not {expected}");
let sheets: Vec<String> = s.model.state().sheets.iter().map(|sh| sh.body.clone()).collect();
assert!(!sheets.is_empty());
for body in sheets {
for id in ["sheet.unfold", "sheet.refold"] {
s.execute(id, &json!({"body": body})).unwrap_or_else(|e| panic!("{id}: {e}"));
}
}
}
// #1255: an axis through a corner round of the sensor enclosure's lid, then a parameter that
// widens the lid. The round is found again by name, but its point nearest the old pick is the
// end of its arc, on the edge it shares with the flat side, and picking again by that point
// took the side: "the face is not a cylinder". The named face is used as found now.
#[test]
fn an_axis_through_a_corner_round_that_a_parameter_moves() {
let mut s = load(&root().join("examples/acceptance/sensor_enclosure.json")).unwrap();
s.execute("construct.axis.through_cylinder", &json!({"face": {"face": [36.242_617, 29.977_740, 27.0]}})).unwrap();
let start = s.doc.features.len();
s.execute("parameters.change", &json!({"name": "board_w", "expression": "(60 mm) * 1.1"})).unwrap();
check(&s, start, &BTreeMap::new()).unwrap_or_else(|(kind, e)| panic!("{kind}: {e}"));
let st = s.model.state();
let axis = st.construct.iter().find_map(|c| match &c.geom {
solidcraft_engine::doc::construct::ConstructGeom::Axis { origin, dir } => Some((*origin, *dir)),
_ => None,
});
let (origin, dir) = axis.expect("the axis");
// The round's axis moved with the lid's corner, from x = 36 to 39.
let off = origin - Vec3::new(39.0, 26.0, 0.0);
assert!((off - dir * off.dot(dir)).len() < 1e-6, "the axis passes {origin:?} along {dir:?}");
}
#[test]
fn a_sketch_on_a_patterned_boss_top() {
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 20.]).into();
+168
View File
@@ -0,0 +1,168 @@
//! `--json-out FILE` writes the JSON `run` and `eval` would print to a file the CLI makes
//! itself. A shell's `>` can re-encode stdout (Windows PowerShell writes UTF-16, or UTF-8 with a
//! byte-order mark), which strict JSON readers refuse; the file is plain UTF-8.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-json-out-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A box, and a parameter whose comment has a character outside ASCII.
fn script(&self) -> PathBuf {
let path = self.0.join("box.json");
let script = json!({"commands": [
{"command": "parameters.add", "params": {"name": "bore", "expression": "5 mm", "comment": "\u{d8} of the bore"}},
{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
]});
std::fs::write(&path, script.to_string()).unwrap();
path
}
/// A filleted box whose fillet a later parameter change makes impossible: every command
/// succeeds, the timeline ends with a failing feature.
fn broken_script(&self) -> PathBuf {
let path = self.0.join("broken.json");
let script = json!({"commands": [
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
]});
std::fs::write(&path, script.to_string()).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
fn stdout_of(output: Output) -> Vec<u8> {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
output.stdout
}
/// Timings differ from one run to the next.
fn without_timings(v: &mut Value) {
match v {
Value::Object(map) => {
map.remove("ms");
map.values_mut().for_each(without_timings);
}
Value::Array(items) => items.iter_mut().for_each(without_timings),
_ => {}
}
}
/// The file holds what stdout carried in the same command without `--json-out`: the same JSON,
/// laid out the same way, in UTF-8 with no byte-order mark (UTF-8's or UTF-16's), so a strict
/// reader accepts it.
fn assert_plain_utf8_json(file: &Path, stdout: &[u8]) -> Value {
let bytes = std::fs::read(file).unwrap();
assert!(matches!(bytes.first(), Some(b'{' | b'[')), "the file starts with the JSON itself, not a mark");
assert_eq!(bytes.last(), Some(&b'\n'), "like stdout, the file ends with a newline");
let lines = |b: &[u8]| b.iter().filter(|c| **c == b'\n').count();
assert_eq!(lines(&bytes), lines(stdout), "laid out like stdout");
let mut value: Value = serde_json::from_str(std::str::from_utf8(&bytes).unwrap()).unwrap();
let mut printed: Value = serde_json::from_slice(stdout).unwrap();
without_timings(&mut value);
without_timings(&mut printed);
assert_eq!(value, printed, "the file is what stdout would have carried");
value
}
#[test]
fn run_results_json_out_holds_what_stdout_would() {
let dir = Scratch::new();
let script = dir.script();
let file = dir.0.join("results.json");
let on_stdout = stdout_of(cli().arg("run").arg(&script).arg("--results").output().unwrap());
let output = cli().arg("run").arg(&script).arg("--results").arg("--json-out").arg(&file).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "the JSON goes to the file, not to stdout");
let results = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(results.as_array().map(Vec::len), Some(2));
}
#[test]
fn run_json_out_without_results_holds_the_measurement_even_when_quiet() {
let dir = Scratch::new();
let script = dir.script();
let on_stdout = stdout_of(cli().arg("run").arg(&script).output().unwrap());
for (name, extra) in [("plain", &[][..]), ("quiet", &["--quiet"][..])] {
let file = dir.0.join(format!("measure-{name}.json"));
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "{name}");
let measure = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(measure["body_count"], 1, "{name}");
}
}
#[test]
fn eval_json_out_holds_what_stdout_would() {
let dir = Scratch::new();
let script = dir.script();
let on_stdout = stdout_of(cli().arg("eval").arg(&script).output().unwrap());
// The option may come before the file to evaluate, and `inspect` is the same command.
for name in ["eval", "inspect"] {
let file = dir.0.join(format!("{name}.json"));
let output = cli().arg(name).arg("--json-out").arg(&file).arg(&script).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "{name}");
let measured = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(measured["body_count"], 1, "{name}");
// A character outside ASCII is written as it is, in UTF-8.
assert!(std::fs::read_to_string(&file).unwrap().contains("\u{d8} of the bore"), "{name}");
}
}
#[test]
fn json_out_needs_a_file_and_fails_when_it_cannot_write() {
let dir = Scratch::new();
let script = dir.script();
for cmd in ["run", "eval"] {
let output = cli().arg(cmd).arg(&script).arg("--json-out").output().unwrap();
assert_eq!(output.status.code(), Some(1), "{cmd}");
assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}");
assert!(output.stdout.is_empty(), "{cmd}");
let missing = dir.0.join("missing").join("out.json");
let output = cli().arg(cmd).arg(&script).arg("--json-out").arg(&missing).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{cmd}");
assert!(!output.stderr.is_empty(), "{cmd}");
assert!(output.stdout.is_empty(), "{cmd}");
assert!(!missing.exists(), "{cmd}");
}
}
#[test]
fn run_json_out_is_not_written_when_a_feature_fails() {
let dir = Scratch::new();
let script = dir.broken_script();
for (name, extra) in [("plain", &[][..]), ("results", &["--results"][..])] {
let file = dir.0.join(format!("failed-{name}.json"));
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{name}");
assert!(String::from_utf8_lossy(&output.stderr).contains("nothing was written"), "{name}");
assert!(!file.exists(), "{name}");
}
}
+74 -9
View File
@@ -1,18 +1,83 @@
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
//! `mcp --connect` to a port where no app listens yet (#270): the server still starts, its tools
//! say how to start the app, and once the app runs they reach it with that launch's token.
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
#[test]
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
// A port that was free a moment ago: nothing listens on it.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
.stdin(Stdio::null())
.output()
let addr = format!("127.0.0.1:{port}");
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-connect-{}-{port}", std::process::id()));
std::fs::create_dir_all(&control_dir).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--connect", &addr, "--allow-any-path"])
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
.env_remove("SOLIDCRAFT_CONTROL_TOKEN")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{stderr}");
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut ask = |id: u64, method: &str, params: Value| -> Value {
writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
let mut line = String::new();
stdout.read_line(&mut line).unwrap();
serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}"))
};
let measure = json!({"name": "measure", "arguments": {}});
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
// The server answers without the app, and its instructions say the app isn't running.
let init = ask(1, "initialize", json!({"protocolVersion": "2025-06-18"}));
let instructions = init["result"]["instructions"].as_str().unwrap_or_default();
assert!(instructions.contains("not running") && instructions.contains(&format!("--control {port}")), "{instructions}");
let tools = ask(2, "tools/list", json!({}));
assert!(tools["result"]["tools"].as_array().is_some_and(|t| !t.is_empty()), "{tools}");
// A tool call says the app is not running and how to start it.
let r = ask(3, "tools/call", measure.clone());
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(text(&r).contains("not running") && text(&r).contains(&format!("solidcraft --control {port}")), "{r}");
// The app starts: a token file for this launch, and its control port.
let token = "0123456789abcdef".repeat(4);
std::fs::write(control_dir.join(format!("{port}.token")), &token).unwrap();
let listener = TcpListener::bind(&addr).unwrap();
let app = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
let reply = if req["token"] == json!(token) {
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
} else {
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
};
writeln!(&stream, "{reply}").unwrap();
});
let r = ask(4, "tools/call", measure);
assert_ne!(r["result"]["isError"], true, "{r}");
assert!(text(&r).contains("engine.execute"), "{r}");
app.join().unwrap();
drop(stdin);
assert!(child.wait().unwrap().success());
std::fs::remove_dir_all(&control_dir).ok();
}
#[test]
fn mcp_connect_without_a_port_is_refused_at_start() {
for addr in ["localhost", "127.0.0.1:0"] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--connect", addr]).stdin(Stdio::null()).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{addr}: {stderr}");
assert!(stderr.contains("HOST:PORT"), "{addr}: {stderr}");
}
}
+38
View File
@@ -132,3 +132,41 @@ fn legacy_extension_and_format_tag_still_open() {
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
assert!(reopened.to_json().contains("\"solidcraft/"));
}
/// `snapshot` starts from `--in` as `run` does (its script runs on that design), and refuses an
/// option it doesn't take rather than ignoring it.
#[test]
fn snapshot_runs_its_script_on_the_in_design() {
let dir = Scratch::new();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let shifted = dir.0.join("shifted.json");
std::fs::write(
&shifted,
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "corner": [10, 0, 0]}}]}).to_string(),
)
.unwrap();
let shot = |name: &str, args: &[&std::ffi::OsStr]| {
let png = dir.0.join(name);
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
std::fs::read(&png).unwrap()
};
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains(says), "{}", String::from_utf8_lossy(&output.stderr));
}
}
/// An unknown command's error suggests the id that was meant.
#[test]
fn unknown_command_suggests_the_closest_id() {
let output = cli().args(["exec", "file.open"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
}
@@ -0,0 +1,217 @@
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
//! design or one the script applies itself, and without the ground grid and the origin's axes
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_mcp::{Headless, Server};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const SIZE: (usize, usize) = (160, 100);
fn camera() -> Value {
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-snapshot-camera-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A box that straddles Z 0 and keeps clear of the origin (its axes show beside it), with a view
/// saved in the design, plus `more` steps.
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
let mut steps = vec![
json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30, "corner": [10, -20, -15]}}),
json!({"command": "view.save", "params": {"name": "Detail", "camera": camera()}}),
];
steps.extend(more.iter().cloned());
let path = self.0.join(name);
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
path
}
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.arg("snapshot")
.arg(script)
.arg("--out")
.arg(self.0.join(out))
.args(["--width", &w, "--height", &h])
.args(extra)
.output()
.unwrap()
}
/// The PNG `snapshot` writes.
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
let output = self.run(script, out, extra);
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
let png = std::fs::read(self.0.join(out)).unwrap();
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
png
}
/// The PNG the MCP `screenshot` tool writes for the same design.
fn mcp(&self, script: &Path, out: &str, arguments: Value) -> Vec<u8> {
let mut session = Session::default();
session.run_script(&serde_json::from_slice(&std::fs::read(script).unwrap()).unwrap()).unwrap();
let mut server = Server::new(Box::new(Headless::new(session)));
let mut arguments = arguments;
arguments["path"] = json!(self.0.join(out).to_string_lossy());
arguments["width"] = json!(SIZE.0);
arguments["height"] = json!(SIZE.1);
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": arguments}});
let reply: Value = serde_json::from_str(&server.handle_line(&call.to_string()).unwrap()).unwrap();
assert!(reply.get("error").is_none() && reply["result"]["isError"] != json!(true), "{reply}");
std::fs::read(self.0.join(out)).unwrap()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The picture of `--camera` is the picture of the MCP `screenshot` with the same camera, and
/// not the home view the option used to be ignored for.
#[test]
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let home = dir.snapshot(&script, "home.png", &[]);
let json = camera().to_string();
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
assert_ne!(shot, home, "--camera was ignored");
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
// A perspective camera too, and a different eye gives a different picture.
let mut near = camera();
near["fov"] = json!(0.6);
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
assert_ne!(perspective, shot);
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
}
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
/// standard view still looks as it did.
#[test]
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
assert_eq!(saved, by_camera);
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
// A standard view frames the model from its direction, as before: the home camera turned.
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
let mut s = Session::default();
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
let mut cam = solidcraft_engine::view::home_camera(&s);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam);
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
assert_ne!(top, saved);
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
assert_ne!(iso, top);
}
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
/// of its snapshot; `--view` and `--camera` still decide when given.
#[test]
fn a_script_can_carry_its_camera() {
let dir = Scratch::new();
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
let plain = dir.script("plain.json", &[]);
let carried = dir.snapshot(&script, "carried.png", &[]);
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
}
/// The grid and the axes each go when asked, and a render without both from any side shows the
/// model alone: an empty design is then the same blank picture from every camera.
#[test]
fn the_grid_and_the_axes_can_be_left_out() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
for view in [&["--view", "front"][..], &["--camera", &json][..]] {
let with = |extra: &[&str], out: &str| dir.snapshot(&script, out, &[view, extra].concat());
let (all, no_grid, no_axes, bare) = (
with(&[], "all.png"),
with(&["--no-grid"], "no-grid.png"),
with(&["--no-axes"], "no-axes.png"),
with(&["--no-grid", "--no-axes"], "bare.png"),
);
let different = [&all, &no_grid, &no_axes, &bare];
for (i, a) in different.iter().enumerate() {
for b in &different[i + 1..] {
assert_ne!(a, b, "{view:?}: two pictures are the same");
}
}
// The same as the MCP's `grid` and `axes`.
let mcp = |extra: Value, out: &str| {
let mut a = match view[0] {
"--view" => json!({"view": "front"}),
_ => json!({"camera": camera()}),
};
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
dir.mcp(&script, out, a)
};
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
}
let empty = dir.0.join("empty.json");
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
}
#[test]
fn a_view_or_a_camera_that_cannot_be_used_is_an_error_and_writes_nothing() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let cases: [(&[&str], &str); 7] = [
(&["--view", "Nowhere"], "Nowhere"),
(&["--view", "iso", "--camera", "{}"], "not both"),
(&["--camera", "not json"], "--camera"),
(&["--camera", "{\"target\": [0, 0, 0]}"], "yaw"),
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 0}"], "distance"),
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 50, \"fov\": -1}"], "camera"),
(&["--camera", "[1, 2, 3]"], "camera"),
];
for (extra, expect) in cases {
let output = dir.run(&script, "never.png", extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
}
}
/// The usage text says what the new options do.
#[test]
fn the_usage_names_the_camera_and_the_guides() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
for word in ["--camera", "--view", "view.save", "--no-grid", "--no-axes"] {
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
}
}
+1 -1
View File
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList",
] }
+13
View File
@@ -23,6 +23,15 @@
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
<meta name="theme-color" content="#343840">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/ai.storyteller.solidcraft.png">
<link rel="apple-touch-icon" href="icons/256x256/ai.storyteller.solidcraft.png">
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
</head>
<body>
<canvas id="solidcraft_canvas"></canvas>
@@ -32,6 +41,10 @@
if ("serviceWorker" in navigator) {
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
}
// Install as an app: the browser's offer can come before the app runs, so it is kept here for
// the app's File ▸ Install app… item (src/install.rs). Installed, the browser makes no more offers.
addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); window.solidcraftInstall = e; });
addEventListener("appinstalled", () => { window.solidcraftInstall = null; });
</script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "SolidCraft",
"short_name": "SolidCraft",
"description": "Open-source parametric CAD built in Rust.",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#e8ebf0",
"theme_color": "#343840",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "icons/32x32/ai.storyteller.solidcraft.png", "sizes": "32x32", "type": "image/png" },
{ "src": "icons/256x256/ai.storyteller.solidcraft.png", "sizes": "256x256", "type": "image/png" },
{ "src": "icons/512x512/ai.storyteller.solidcraft.png", "sizes": "512x512", "type": "image/png" }
]
}
+67
View File
@@ -0,0 +1,67 @@
//! Install as an app (`solidcraft_ui_egui::install`). The browser offers it with a
//! `beforeinstallprompt` event, which can come before this code runs: index.html keeps the event
//! as `window.solidcraftInstall` (and drops it once the app is installed). The offer is good for
//! one prompt; a browser that is still willing makes a new one later. Safari makes no offer: it
//! gets its steps, unless the app is already running installed.
use solidcraft_ui_egui::install::{InstallApp, Offer, safari_steps};
use wasm_bindgen::{JsCast as _, JsValue};
/// Where index.html keeps the browser's offer.
const OFFER: &str = "solidcraftInstall";
/// The browser's offer, kept by index.html (None: none, or it was used).
fn offer() -> Option<js_sys::Object> {
let window = web_sys::window()?;
js_sys::Reflect::get(&window, &OFFER.into()).ok()?.dyn_into().ok()
}
/// Show the browser's install prompt (from the File menu click, so the page may).
fn prompt() {
let Some(offer) = offer() else { return };
if let Some(window) = web_sys::window() {
let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL);
}
let shown = js_sys::Reflect::get(&offer, &"prompt".into())
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(JsValue::from))
.and_then(|f| f.call0(&offer))
.and_then(|p| p.dyn_into::<js_sys::Promise>().map_err(JsValue::from));
match shown {
// Accepted or dismissed, the browser handles it; a refusal is only logged.
Ok(p) => wasm_bindgen_futures::spawn_local(async move {
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
log::warn!("install prompt: {e:?}");
}
}),
Err(e) => log::warn!("install prompt: {e:?}"),
}
}
/// The page runs as the installed app (its own window, or from the Home Screen on iOS).
fn running_installed(window: &web_sys::Window) -> bool {
let standalone = window.match_media("(display-mode: standalone)").ok().flatten().is_some_and(|m| m.matches());
let home_screen = js_sys::Reflect::get(&window.navigator(), &"standalone".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
standalone || home_screen
}
/// The host service for File ▸ Install app…; the UI is drawn again whenever the browser
/// makes or withdraws its offer (even with nothing else happening).
pub fn service(ctx: &egui::Context) -> InstallApp {
// Safari's steps, decided once: the browser and the way the page runs don't change.
let steps = web_sys::window().filter(|w| !running_installed(w)).and_then(|w| {
let nav = w.navigator();
let vendor = js_sys::Reflect::get(&nav, &"vendor".into()).ok().and_then(|v| v.as_string()).unwrap_or_default();
safari_steps(&nav.user_agent().unwrap_or_default(), &vendor, nav.max_touch_points())
});
if let Some(window) = web_sys::window() {
for event in ["beforeinstallprompt", "appinstalled"] {
let ctx = ctx.clone();
let redraw = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
let _ = window.add_event_listener_with_callback(event, redraw.as_ref().unchecked_ref());
// The listener lives as long as the page.
redraw.forget();
}
}
let offered = move || if offer().is_some() { Some(Offer::Prompt) } else { steps.map(Offer::Steps) };
InstallApp { offer: Box::new(offered), prompt: Box::new(prompt) }
}
+32 -3
View File
@@ -2,12 +2,14 @@
//!
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
//! sample part.
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until
//! `?webgpu`), `?sample` opens the sample part.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod files;
#[cfg(target_arch = "wasm32")]
mod install;
#[cfg(target_arch = "wasm32")]
mod web {
@@ -398,6 +400,29 @@ mod web {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Where `?webgl` is remembered. The installed app starts from its manifest's start URL, which
/// has no query, so the choice has to outlive the page it was made on.
const WEBGL_KEY: &str = "solidcraft.webgl";
/// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`).
fn webgl() -> bool {
let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten());
let q = query();
if q.contains("webgl") {
if let Some(s) = &storage {
let _ = s.set_item(WEBGL_KEY, "1");
}
return true;
}
if q.contains("webgpu") {
if let Some(s) = &storage {
let _ = s.remove_item(WEBGL_KEY);
}
return false;
}
storage.and_then(|s| s.get_item(WEBGL_KEY).ok().flatten()).is_some()
}
/// Fetch font bytes asynchronously through the browser.
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
let window = web_sys::window().ok_or("no window")?;
@@ -422,7 +447,7 @@ mod web {
return;
};
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
if query().contains("webgl")
if webgl()
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@@ -447,6 +472,7 @@ mod web {
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))))
})),
install_app: Some(crate::install::service(&cc.egui_ctx)),
..Default::default()
};
let mut app = SolveApp::new(Session::default(), services);
@@ -465,6 +491,9 @@ mod web {
app.session.mark_saved();
}
if !query().contains("sample") {
// The empty design takes Units of new designs (#1205). The sample
// part is built in millimetres and is left that way.
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
let rev = app.session.revision;
app.home.show(rev);
}
+23 -2
View File
@@ -7,8 +7,9 @@ license.workspace = true
publish = false
default-run = "solidcraft"
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs can
# allow it (macOS file-open events have no safe API). Keep the rest in sync with [workspace.lints].
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs
# (macOS file-open events), src/native_crash.rs (Windows exception filter) and src/native_frame.rs
# (Windows resize border) can allow it: none has a safe API. Keep the rest in sync with [workspace.lints].
[lints.rust]
unsafe_code = "deny"
@@ -35,12 +36,32 @@ getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
# API keys of the agent chat's API agents (docs/agent-chat.md) live in the system keychain:
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
# The main window's handle for src/native_frame.rs (already in the tree through eframe and winit).
raw-window-handle = "0.6"
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_Debug",
"Win32_System_IO",
"Win32_System_Kernel",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Threading",
"Win32_UI_HiDpi",
"Win32_UI_WindowsAndMessaging",
] }
[target.'cfg(target_os = "macos")'.dependencies]
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
+40 -14
View File
@@ -18,8 +18,9 @@ const MAX_CONNECTIONS: usize = 16;
const MAX_BAD_LINES: usize = 8;
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token file this launch wrote, removed when the app ends.
static TOKEN_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// The token files this launch wrote (its `--control` port, the agent chat's endpoint), removed
/// when the app ends.
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
/// A new random token: 32 bytes from the operating system, as hex.
fn new_token() -> Option<String> {
@@ -30,30 +31,55 @@ fn new_token() -> Option<String> {
/// Remove this launch's token file (at exit).
pub fn remove_token_file() {
if let Some(p) = TOKEN_FILE.get() {
let _ = std::fs::remove_file(p);
if let Ok(files) = TOKEN_FILES.lock() {
for p in files.iter() {
let _ = std::fs::remove_file(p);
}
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
// Every connection must show the token before anything runs (docs/control-protocol.md).
let Some(token) = control_auth::env_token().or_else(new_token) else {
eprintln!("solidcraft: control server not started: no random token available");
return rx;
match control_auth::env_token().or_else(new_token) {
Some(token) => {
listen(port, token, tx, ctx, false);
}
None => eprintln!("solidcraft: control server not started: no random token available"),
}
rx
}
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
/// port with a fresh token of its own (never `SOLIDCRAFT_CONTROL_TOKEN`), written to the usual
/// token file, where the MCP server finds it, so the token never passes through the agent. Its
/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow.
pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u16> {
let Some(token) = new_token() else {
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
return None;
};
listen(0, token, tx, ctx, true)
}
/// Listen on `port` (0: a free port the system picks) with `token`; the port it listens on.
/// `patient`: every request may wait as long as a batch (a person decides on it).
fn listen(port: u16, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
return None;
}
};
let port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
match control_auth::token_path(port) {
Some(path) => match control_auth::write_token_file(&path, &token) {
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
let _ = TOKEN_FILE.set(path);
if let Ok(mut files) = TOKEN_FILES.lock() {
files.push(path);
}
}
Err(e) => eprintln!(
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
@@ -78,12 +104,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let ctx = ctx.clone();
let token = token.clone();
std::thread::spawn(move || {
serve(stream, &token, tx, ctx);
serve(stream, &token, tx, ctx, patient);
OPEN.fetch_sub(1, Ordering::Relaxed);
});
}
});
rx
Some(port)
}
/// One line without its newline, or `None` at the end of the stream. A client that sends
@@ -134,7 +160,7 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
@@ -168,7 +194,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = Duration::from_secs(if method == "engine.batch" { 3600 } else { 60 });
let wait = Duration::from_secs(if patient || method == "engine.batch" { 3600 } else { 60 });
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
@@ -223,7 +249,7 @@ mod tests {
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default()));
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default(), false));
}
});
let session = |lines: &[String]| {
+136 -2
View File
@@ -19,6 +19,10 @@
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(target_os = "windows")]
mod native_crash;
#[cfg(target_os = "windows")]
mod native_frame;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux"))]
@@ -32,14 +36,24 @@ struct App {
solve: SolveApp,
/// The settings folder whose start marker the first frame clears (`startup::started`).
starting: Option<std::path::PathBuf>,
/// Frames begun; the second one means the first was drawn (startup is over).
frames: u32,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse: space_mouse::SpaceMouse,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(dir) = self.starting.take() {
startup::started(&dir);
if self.frames < 2 {
self.frames += 1;
// A second frame right away, which means the first was presented: the app is up.
ctx.request_repaint();
if self.frames == 2 {
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
}
}
// The design on screen is the one whose commands a crash report replays.
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
@@ -72,6 +86,8 @@ impl eframe::App for App {
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
}
fn on_exit(&mut self) {
// The agent chat's agent and everything it started end with the app.
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
// An update asked to restart into the new version or to run its installer.
@@ -86,6 +102,7 @@ impl eframe::App for App {
fn services(cc: &eframe::CreationContext<'_>) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
let open_parent = parent.clone();
let program_parent = parent.clone();
Services {
pick_open: Some(Box::new(move || {
let future = open_parent
@@ -119,6 +136,12 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_program: Some(Box::new(move || {
let dialog = program_parent.clone().set_title("Choose your slicer");
let dialog = if cfg!(target_os = "windows") { dialog.add_filter("Programs", &["exe"]) } else { dialog };
let future = dialog.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
let dialog = parent.clone().set_file_name(name);
let dialog = if exts == [io::DESIGN_EXT, "json"] {
@@ -136,6 +159,10 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
http_get: Some(std::sync::Arc::new(http_get)),
http_download: Some(std::sync::Arc::new(http_download)),
// Already an installed app: only the browser build offers to install.
install_app: None,
agent_http: Some(std::sync::Arc::new(agent_http)),
secrets: Some(std::sync::Arc::new(Keychain)),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
@@ -192,6 +219,71 @@ fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<
Ok(())
}
/// HTTP for the agent chat's API agents (OpenAI-compatible endpoints, docs/agent-chat.md): to the
/// URL the user configured, which may be plain HTTP on this computer (Ollama, LM Studio). No
/// redirects (a key must not follow one elsewhere), at most 16 MB back.
fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Result<(u16, Vec<u8>), String> {
use std::time::Duration;
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls)
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 900))))
.timeout_connect(Some(Duration::from_secs(10)))
.user_agent("SolidCraft")
.max_redirects(0)
.build()
.into();
let mut response = if r.method == "POST" {
let mut req = agent.post(&r.url);
for (k, v) in &r.headers {
req = req.header(k, v);
}
req.send(r.body.as_deref().unwrap_or_default())
} else {
let mut req = agent.get(&r.url);
for (k, v) in &r.headers {
req = req.header(k, v);
}
req.call()
}
.map_err(|e| e.to_string())?;
let status = response.status().as_u16();
let body = response.body_mut().with_config().limit(16 << 20).read_to_vec().map_err(|e| e.to_string())?;
Ok((status, body))
}
/// API keys in the system keychain (service "SolidCraft agent chat", one entry per agent).
struct Keychain;
impl Keychain {
fn entry(id: &str) -> Result<keyring::Entry, String> {
keyring::Entry::new("SolidCraft agent chat", id).map_err(|e| e.to_string())
}
}
impl solidcraft_ui_egui::agent_chat::backend::Secrets for Keychain {
fn get(&self, id: &str) -> Result<Option<String>, String> {
match Self::entry(id)?.get_password() {
Ok(p) => Ok(Some(p)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
fn set(&self, id: &str, secret: &str) -> Result<(), String> {
Self::entry(id)?.set_password(secret).map_err(|e| e.to_string())
}
fn delete(&self, id: &str) -> Result<(), String> {
match Self::entry(id)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
}
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
@@ -392,6 +484,12 @@ fn main() -> eframe::Result {
solidcraft_engine::crash::install(dir, build)
})
.unwrap_or_default();
// A crash in native code (a graphics driver) says where it happened: the exception and the
// module on Windows, and on every system the startup step the app was in.
#[cfg(target_os = "windows")]
native_crash::install();
eframe::egui_wgpu::set_startup_hook(startup_step);
solidcraft_engine::crash::phase("window");
let options = eframe::NativeOptions {
viewport: app_viewport()?,
depth_buffer: DEPTH_BITS,
@@ -435,9 +533,20 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
solidcraft_engine::crash::phase("app setup");
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services(cc));
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
app.custom_titlebar = CUSTOM_TITLEBAR;
// Windows: the OS hit test offers the resize border, at the window's DPI, from the
// first message (#1381).
#[cfg(target_os = "windows")]
{
app.native_resize_border = CUSTOM_TITLEBAR && native_frame::install(cc);
if CUSTOM_TITLEBAR && !app.native_resize_border {
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
}
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.cpu_meshing = safe_mode;
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
@@ -445,7 +554,12 @@ fn main() -> eframe::Result {
}
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
// 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.
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
let info = rs.adapter.get_info();
@@ -458,6 +572,10 @@ fn main() -> eframe::Result {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
// on a free port the first time the chat connects (docs/agent-chat.md).
let ctx = cc.egui_ctx.clone();
app.agent_chat.control_starter = Some(Box::new(move |tx| control_server::listen_agent(tx, ctx.clone())));
if sample {
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
app.set_status(format!("sample: {e}"), true);
@@ -475,6 +593,11 @@ fn main() -> eframe::Result {
for f in &files {
app.open_path_async(f);
}
// No file and no sample: the empty design takes Units of new designs (#1205).
// An opened file and the sample part keep the units they were saved or built with.
if files.is_empty() && !sample {
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
}
// No design given: the start page (not when driven over the control channel, whose
// clients expect the design view; `ui.home` shows it there).
if files.is_empty() && !sample && control_port.is_none() {
@@ -497,9 +620,11 @@ fn main() -> eframe::Result {
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
solidcraft_engine::crash::phase("first frame");
Ok(Box::new(App {
solve: app,
starting,
frames: 0,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse,
}))
@@ -512,6 +637,15 @@ fn main() -> eframe::Result {
}
}
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up:
/// the crash journal keeps it, and the adapter once it is picked.
fn startup_step(step: &'static str, detail: &str) {
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
}
solidcraft_engine::crash::phase(step);
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
/// in use is started); `preference` is `graphics.json`'s `backend`.
+285
View File
@@ -0,0 +1,285 @@
//! Windows: what a crash in native code leaves behind (docs/crash-reports.md).
//!
//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no
//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An
//! unhandled-exception filter now writes, before the process goes, a small JSON file with the
//! exception code, the faulting module and the offset into it, and a minidump, next to the run's
//! crash journal (`solidcraft_engine::crash::native_paths`). The next start puts both into the
//! unclean exit's report, with the startup step the journal says the app was in.
//!
//! Installing an exception filter and asking the system which module holds an address have no
//! safe API, hence the `unsafe_code` allowance on this module (the app crate denies it; see
//! `open_documents.rs` for the other one). The filter runs on a thread that may have a broken
//! heap or almost no stack left: it doesn't allocate, formats into a buffer on the stack, and
//! skips the minidump after a stack overflow.
#![allow(unsafe_code)]
use std::fmt::Write as _;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile};
use windows_sys::Win32::System::Diagnostics::Debug::{
EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo,
MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter,
};
use windows_sys::Win32::System::LibraryLoader::{
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};
/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`].
struct Paths {
info: Vec<u16>,
dump: Vec<u16>,
}
static PATHS: OnceLock<Paths> = OnceLock::new();
/// The filter that was installed before ours (called after ours), as an address.
static PREVIOUS: AtomicUsize = AtomicUsize::new(0);
/// The filter has started writing (a second exception while it does is left alone).
static BUSY: AtomicBool = AtomicBool::new(false);
const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005;
const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006;
const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD;
/// Catch native crashes from now on (after `solidcraft_engine::crash::install`; nothing without it).
pub fn install() {
let Some((info, dump)) = solidcraft_engine::crash::native_paths() else { return };
let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::<Vec<u16>>();
if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() {
set_filter();
}
}
/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own,
/// which is then called after ours.
pub fn reinstall() {
if PATHS.get().is_some() {
set_filter();
}
}
fn set_filter() {
// SAFETY: `filter` has the signature the system calls it with and stays valid for the
// process's life.
let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) };
if let Some(p) = previous
&& p as usize != filter as *const () as usize
{
PREVIOUS.store(p as usize, Ordering::SeqCst);
}
}
/// The unhandled-exception filter: record, then let the previous filter (or the system) go on.
unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 {
if !BUSY.swap(true, Ordering::SeqCst) {
// SAFETY: the system passes valid exception pointers for the duration of the call.
unsafe { record(pointers) };
}
let previous = PREVIOUS.load(Ordering::SeqCst);
if previous != 0 && previous != filter as *const () as usize {
// SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type.
let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) };
// SAFETY: as for this filter.
return unsafe { previous(pointers) };
}
EXCEPTION_CONTINUE_SEARCH
}
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
struct Buf {
bytes: [u8; 2048],
len: usize,
}
impl std::fmt::Write for Buf {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let room = self.bytes.len().saturating_sub(self.len);
let n = s.len().min(room);
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
to.copy_from_slice(from);
self.len += n;
}
Ok(())
}
}
/// The usual names of the exception codes a crash shows.
fn code_name(code: u32) -> &'static str {
match code {
STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION",
STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR",
STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW",
0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION",
0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO",
0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION",
0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED",
0xC000_0374 => "STATUS_HEAP_CORRUPTION",
0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN",
0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION",
0x8000_0003 => "EXCEPTION_BREAKPOINT",
0xE06D_7363 => "C++ exception",
_ => "exception",
}
}
/// Write what `pointers` says into the info file, then the minidump.
unsafe fn record(pointers: *const EXCEPTION_POINTERS) {
let Some(paths) = PATHS.get() else { return };
if pointers.is_null() {
return;
}
// SAFETY: checked non-null; the system keeps it valid during the filter.
let rec = unsafe { (*pointers).ExceptionRecord };
if rec.is_null() {
return;
}
// SAFETY: as above.
let rec = unsafe { &*rec };
let code = rec.ExceptionCode as u32;
let address = rec.ExceptionAddress as usize;
let mut module: HMODULE = std::ptr::null_mut();
let mut name = [0u16; 512];
// SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no
// handle to release; `name` is as long as we say.
let len = unsafe {
if GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
address as *const u16,
&mut module,
) != 0
{
GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize
} else {
0
}
};
// SAFETY: no preconditions.
let thread = unsafe { GetCurrentThreadId() };
let mut b = Buf { bytes: [0; 2048], len: 0 };
let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code));
if len > 0 {
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize));
for c in char::decode_utf16(name.iter().take(len).copied()) {
let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) {
'"' => b.write_str("\\\""),
'\\' => b.write_str("\\\\"),
c if (c as u32) < 0x20 => b.write_str("?"),
c => b.write_char(c),
};
}
let _ = b.write_str("\"");
}
if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 {
let what = match rec.ExceptionInformation[0] {
0 => "read of",
1 => "write to",
8 => "execution at",
_ => "access to",
};
let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]);
}
let _ = b.write_str("}");
let bytes = b.bytes.get(..b.len).unwrap_or_default();
// SAFETY: NUL-terminated path; the handle is closed below.
unsafe {
let file = create(&paths.info);
if !file.is_null() {
let mut written = 0u32;
WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut());
CloseHandle(file);
}
}
// After a stack overflow there is no stack left for writing a minidump.
if code == STATUS_STACK_OVERFLOW {
return;
}
// SAFETY: as above; the exception information points at this thread's exception.
unsafe {
let file = create(&paths.dump);
if !file.is_null() {
let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 };
MiniDumpWriteDump(
GetCurrentProcess(),
GetCurrentProcessId(),
file,
MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules,
&exception,
std::ptr::null(),
std::ptr::null(),
);
CloseHandle(file);
}
}
}
/// A new file at `path` (NUL-terminated) for writing, or null.
unsafe fn create(path: &[u16]) -> HANDLE {
// SAFETY: the caller passes a NUL-terminated path.
let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) };
if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h }
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
/// The child half: install crash reporting and the filter, then fault in native code.
#[test]
fn child() {
let Some(dir) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_CHILD") else { return };
let build = solidcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234", built: "2026-10-14" };
let fresh = solidcraft_engine::crash::install(PathBuf::from(dir), build);
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
if std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_FAULT").is_none() {
solidcraft_engine::crash::end_session();
return;
}
super::install();
solidcraft_engine::crash::phase("device creation");
// SAFETY: none; this is the crash under test (a read of address 16, as a null struct
// pointer's field would be).
let v = unsafe { std::ptr::read_volatile(16 as *const u64) };
println!("unreachable {v}");
}
fn run(dir: &std::path::Path, fault: bool) -> std::process::Output {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("SOLIDCRAFT_NATIVE_CRASH_CHILD", dir);
if fault {
c.env("SOLIDCRAFT_NATIVE_CRASH_FAULT", "1");
}
c.output().unwrap()
}
#[test]
fn an_access_violation_is_reported_with_its_module_step_and_minidump() {
let dir = std::env::temp_dir().join(format!("solidcraft-native-crash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = run(&dir, true);
assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}");
let sessions: Vec<PathBuf> = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect();
let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught");
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
assert_eq!(v["code"], "0xC0000005", "{v}");
assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}");
assert!(v["module"].as_str().unwrap().to_lowercase().contains("solidcraft"), "the fault is in the test binary: {v}");
let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump");
assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..]));
// The next start reports it.
let out = run(&dir, false);
assert!(out.status.success(), "{out:?}");
let reports = solidcraft_engine::crash::list(&dir);
assert_eq!(reports.len(), 1, "{reports:?}");
let r = solidcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in solidcraft"), "{r}");
assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}");
assert!(r.get("minidump_path").is_some(), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
+177
View File
@@ -0,0 +1,177 @@
//! Windows: the resize border of the window without OS decorations (#1381).
//!
//! The window keeps its native thick frame (`WS_THICKFRAME`, so Aero Snap, Win+arrows and the
//! OS's own move and size loops work) but winit draws no frame: its `WM_NCCALCSIZE` makes the
//! whole window client area, and every point then hit-tests as `HTCLIENT`. This module puts the
//! resize border back by answering `WM_NCHITTEST` itself: within Windows' resize-border width
//! for the window's current DPI from each edge (`solidcraft_ui_egui::titlebar::resize_hit`;
//! 8 px at 100 %, corners win), it returns `HTLEFT`, `HTTOPRIGHT`, … So from the first message
//! the OS shows its resize cursors and runs its own resize, whatever egui has laid out, whatever
//! the scale; nothing waits for a first frame or a first size change.
//!
//! It also answers `WM_GETDPISCALEDSIZE` with the size the window will really have at the new
//! DPI (the same logical size, as winit computes it), so the rect Windows suggests while the
//! title bar is dragged onto a monitor with another scale is the rect winit then applies: the
//! window rescales once at the crossing and stays under the pointer, instead of the OS's move
//! loop and winit disagreeing about a frame the window doesn't have.
//!
//! Replacing a window procedure has no safe API, hence the `unsafe_code` allowance on this
//! module (the app crate denies it; see `native_crash.rs` and `open_documents.rs` for the others).
//! The procedure is installed once, on the thread that owns the window, for the main window only,
//! and calls the previous one (winit's) for everything it doesn't answer.
#![allow(unsafe_code)]
use std::sync::atomic::{AtomicIsize, Ordering};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use solidcraft_ui_egui::titlebar;
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
use windows_sys::Win32::UI::WindowsAndMessaging::{
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IsIconic, IsZoomed, SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SetWindowLongPtrW, WM_GETDPISCALEDSIZE,
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
};
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
/// Answer the main window's hit test and DPI-size questions from now on. Call it once, on the
/// thread that created the window (eframe's app creator). `false` if it isn't a Win32 window.
pub fn install(window: &impl HasWindowHandle) -> bool {
let Ok(handle) = window.window_handle() else { return false };
let RawWindowHandle::Win32(w) = handle.as_raw() else { return false };
let hwnd = w.hwnd.get() as HWND;
if PREVIOUS.load(Ordering::SeqCst) != 0 {
return false;
}
// SAFETY: `hwnd` is the live window eframe just created on this thread; `frame_proc` has the
// WNDPROC signature and stays valid for the life of the process. SetWindowLongPtrW returns
// the previous procedure, which `frame_proc` calls for every other message.
let previous = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, frame_proc as *const () as isize as _) };
// `as _`: on 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
// returns i32.
if previous == 0 {
return false;
}
PREVIOUS.store(previous as _, Ordering::SeqCst);
true
}
/// The procedure ours replaced.
fn previous() -> WNDPROC {
let p = PREVIOUS.load(Ordering::SeqCst);
// SAFETY: a nonzero value is the WNDPROC that SetWindowLongPtrW returned in `install`.
(p != 0).then(|| unsafe { std::mem::transmute::<isize, unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT>(p) })
}
unsafe extern "system" fn frame_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
let prev = previous();
// SAFETY: the arguments are the message as Windows delivered it to this window.
let call_previous = || unsafe { CallWindowProcW(prev, hwnd, msg, wparam, lparam) };
match msg {
WM_NCHITTEST => {
let default = call_previous();
// Screen coordinates, signed (monitors left of or above the primary one).
let (x, y) = (i32::from(lparam as u16 as i16), i32::from((lparam >> 16) as u16 as i16));
// The border, else what winit and the system say (`HTCLIENT` everywhere else).
border_hit(hwnd, x, y).map_or(default, |hit| hit as LRESULT)
}
WM_GETDPISCALEDSIZE => {
let new_dpi = wparam as u32;
// SAFETY: for this message lParam points to a SIZE the system reads back.
match (resizable(hwnd), client_size(hwnd), unsafe { (lparam as *mut SIZE).as_mut() }) {
(true, Some(size), Some(out)) => {
// SAFETY: a window handle the system passed in.
let old_dpi = unsafe { GetDpiForWindow(hwnd) };
match titlebar::dpi_scaled_size(size, old_dpi, new_dpi) {
Some((cx, cy)) => {
*out = SIZE { cx, cy };
1
}
None => call_previous(),
}
}
_ => call_previous(),
}
}
WM_NCDESTROY => {
// Hand the window back to winit's procedure for its last message.
if prev.is_some() {
// SAFETY: restores the procedure this window had before `install`.
unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, PREVIOUS.load(Ordering::SeqCst) as _) };
}
call_previous()
}
_ => call_previous(),
}
}
/// The window can be resized by its border now: it has the thick frame (not fullscreen, which
/// drops it) and is neither maximized nor minimized.
fn resizable(hwnd: HWND) -> bool {
// SAFETY: plain queries on a window handle the system passed in.
unsafe {
let style = GetWindowLongPtrW(hwnd, GWL_STYLE) as u32;
style & WS_THICKFRAME != 0 && IsZoomed(hwnd) == 0 && IsIconic(hwnd) == 0
}
}
/// The client area's size in pixels (the whole window: winit gives it no frame).
fn client_size(hwnd: HWND) -> Option<(i32, i32)> {
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
// SAFETY: `r` is a valid RECT to fill.
(unsafe { GetClientRect(hwnd, &mut r) } != 0).then_some((r.right - r.left, r.bottom - r.top))
}
/// Windows' resize border at the window's DPI: the sizing frame plus its padding, as Windows
/// itself sizes it, or 8 px scaled when the system has no answer.
fn border_px(hwnd: HWND) -> f32 {
// SAFETY: plain queries.
let (dpi, metric) = unsafe {
let dpi = GetDpiForWindow(hwnd);
(dpi, GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi))
};
let scale = if dpi > 0 { dpi as f32 / 96.0 } else { 1.0 };
if metric > 0 { metric as f32 } else { titlebar::resize_border_px(scale) }
}
/// The `HT*` resize code at screen point (`x`, `y`), if it is on the border.
fn border_hit(hwnd: HWND, x: i32, y: i32) -> Option<u32> {
if !resizable(hwnd) {
return None;
}
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
// SAFETY: `r` is a valid RECT to fill.
if unsafe { GetWindowRect(hwnd, &mut r) } == 0 {
return None;
}
let w = egui::Rect::from_min_max(egui::pos2(r.left as f32, r.top as f32), egui::pos2(r.right as f32, r.bottom as f32));
titlebar::resize_hit(w, egui::pos2(x as f32, y as f32), border_px(hwnd)).map(hit_code)
}
fn hit_code(d: egui::ResizeDirection) -> u32 {
use egui::ResizeDirection::*;
match d {
North => HTTOP,
South => HTBOTTOM,
West => HTLEFT,
East => HTRIGHT,
NorthWest => HTTOPLEFT,
NorthEast => HTTOPRIGHT,
SouthWest => HTBOTTOMLEFT,
SouthEast => HTBOTTOMRIGHT,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_direction_has_its_own_hit_code() {
use egui::ResizeDirection::*;
let codes: Vec<u32> = [North, South, West, East, NorthWest, NorthEast, SouthWest, SouthEast].into_iter().map(hit_code).collect();
assert_eq!(codes, vec![HTTOP, HTBOTTOM, HTLEFT, HTRIGHT, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT]);
}
}
+67 -2
View File
@@ -48,6 +48,21 @@ impl Default for DriverMapping {
}
}
impl DriverMapping {
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is
/// what the driver applies by default in object mode (#1172): the physical Ry and Rz swapped
/// and reversed, so a tilt left/right rolls and a twist spins. Elsewhere the driver writes a
/// profile, and the raw axes are used until it does.
fn without_profile() -> Self {
let mut mapping = Self::default();
if cfg!(target_os = "linux") {
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
}
mapping
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Motion {
translation: [f64; 3],
@@ -377,7 +392,12 @@ impl SpaceMouse {
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
Self {
shared,
last_frame: now,
driver_mapping: load_driver_mapping().unwrap_or_else(DriverMapping::without_profile),
mapping_checked_at: now,
}
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
@@ -385,7 +405,7 @@ impl SpaceMouse {
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
let updated = load_driver_mapping().unwrap_or_default();
let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile);
if updated != self.driver_mapping {
log::info!("3D mouse: applying updated 3DxWare axis profile");
self.driver_mapping = updated;
@@ -580,6 +600,51 @@ mod tests {
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
}
#[test]
fn without_a_profile_linux_uses_the_drivers_object_mode_axes() {
// 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact).
let object_mode = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>true</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
if cfg!(target_os = "linux") {
assert_eq!(DriverMapping::without_profile(), profile);
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
let before = Camera::default();
let mut tilt = before;
navigate(
&mut tilt,
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }),
0.02,
600.0,
false,
false,
);
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
assert_ne!(tilt.roll, before.roll);
let mut twist = before;
navigate(
&mut twist,
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }),
0.02,
600.0,
false,
false,
);
assert_ne!(twist.yaw, before.yaw);
assert_eq!(twist.roll, before.roll);
} else {
assert_eq!(DriverMapping::without_profile(), DriverMapping::default());
}
}
#[test]
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
let profile = br#"
+1 -1
View File
@@ -75,7 +75,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` |
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
| Web | HTML/Trunk stays in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/` and uses the collected CSS; no favicon/PWA icon currently exists |
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
## Code and provenance
+2 -2
View File
@@ -100,7 +100,7 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
let face = |col: Color32, pts: &[(f32, f32)]| {
p.add(egui::Shape::convex_polygon(m(pts), col, Stroke::NONE));
};
let (light, mid, dark) = (Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197));
let [light, mid, dark, edge_col] = crate::theme::Tokens::get().logo;
face(light, &[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)]);
face(light, &[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)]);
face(mid, &[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)]);
@@ -111,7 +111,7 @@ pub(crate) fn brand_mark(p: &egui::Painter, c: Pos2) {
face(dark, &[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)]);
face(dark, &[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)]);
// White edge lines.
let edge = Stroke::new(10.0 * s, Color32::WHITE);
let edge = Stroke::new(10.0 * s, edge_col);
let line = |pts: &[(f32, f32)]| {
p.add(egui::Shape::line(m(pts), edge));
};
+82 -73
View File
@@ -3,13 +3,6 @@
use egui::{Color32, Mesh, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
/// Surface sheets: a sheet, a second sheet beside it, a lighter sheet (an opening, a blend or
/// round strip), and the iso lines and comb teeth drawn on sheets.
const SHEET: Color32 = Color32::from_rgb(170, 200, 240);
const SHEET_2: Color32 = Color32::from_rgb(150, 185, 235);
const SHEET_LIGHT: Color32 = Color32::from_rgb(214, 229, 248);
const SHEET_LINE: Color32 = Color32::from_rgb(127, 166, 218);
/// `n + 1` points along the cubic Bézier curve with control points `c`.
fn bezier(c: [(f32, f32); 4], n: usize) -> Vec<(f32, f32)> {
(0..=n)
@@ -127,6 +120,12 @@ fn line_type_dot_radius(r: Rect) -> f32 {
/// Paint the icon `name` into `r`. `accent` highlights the "action" part.
pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32) {
paint_with(p, r, name, ink, fill, accent, &crate::theme::Tokens::get().icons);
}
/// [`paint`] with the colours an icon draws of its own given (`theme::IconColors`).
pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, accent: Color32, ic: &crate::theme::IconColors) {
let ic = *ic;
let pen = Pen { p, r, ink, fill, accent, w: (r.width() / 24.0 * 1.4).max(1.0) };
let a = pen.accent;
match name {
@@ -164,7 +163,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"sphere" => {
pen.circle(12.0, 12.0, 8.5, pen.fill, ink);
pen.arc(12.0, 12.0, 8.5, 3.3, 6.1, ink.gamma_multiply(0.5));
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), Color32::from_white_alpha(160));
p.circle_filled(pen.at(9.0, 8.5), 2.0 * pen.s(), ic.glint);
}
"torus" => {
let pts: Vec<Pos2> =
@@ -172,7 +171,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
p.add(Shape::convex_polygon(pts, pen.fill, Stroke::new(pen.w, ink)));
let pts: Vec<Pos2> =
(0..40).map(|i| i as f32 / 40.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 3.5 * t.cos(), 11.0 + 1.8 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
}
"fillet" => {
pen.poly(&[(4.0, 20.0), (4.0, 4.0), (10.0, 4.0), (10.0, 14.0), (20.0, 14.0), (20.0, 20.0)], pen.fill, Color32::TRANSPARENT);
@@ -214,7 +213,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink);
let pts: Vec<Pos2> =
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, a)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, a)));
pen.line(&[(7.0, 8.0), (7.0, 16.0)], a);
pen.line(&[(17.0, 8.0), (17.0, 16.0)], a);
}
@@ -231,7 +230,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"shell" => {
pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0);
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a);
pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], ic.highlight, a);
}
"draft" => {
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], pen.fill, ink);
@@ -272,6 +271,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink);
pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink);
}
// Agent chat: a speech bubble with three dots.
"agent_chat" => {
pen.closed(&[(4.0, 5.0), (20.0, 5.0), (20.0, 16.0), (11.0, 16.0), (6.5, 20.0), (7.5, 16.0), (4.0, 16.0)], ink);
for x in [8.5, 12.0, 15.5] {
pen.dot(x, 10.5, ink);
}
}
// Help: a question mark in a ring.
"help" => {
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
@@ -478,7 +484,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"c_coincident" => {
pen.line(&[(3.0, 18.0), (12.0, 12.0)], ink);
pen.line(&[(12.0, 12.0), (21.0, 16.0)], ink);
pen.circle(12.0, 12.0, 2.6, Color32::WHITE, a);
pen.circle(12.0, 12.0, 2.6, ic.highlight, a);
}
"c_tangent" => {
pen.circle(12.0, 14.0, 6.0, Color32::TRANSPARENT, ink);
@@ -525,7 +531,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.arrow((x1 + (x0 - x1) * 0.15, 9.0), (x1, 9.0), ink);
}
"new" => {
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], Color32::WHITE, ink);
pen.poly(&[(6.0, 3.0), (15.0, 3.0), (19.0, 7.0), (19.0, 21.0), (6.0, 21.0)], ic.highlight, ink);
pen.line(&[(15.0, 3.0), (15.0, 7.0), (19.0, 7.0)], ink);
}
"open" => {
@@ -533,7 +539,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"save" => {
pen.poly(&[(4.0, 4.0), (17.0, 4.0), (20.0, 7.0), (20.0, 20.0), (4.0, 20.0)], pen.fill, ink);
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], Color32::WHITE, ink);
pen.poly(&[(8.0, 4.0), (16.0, 4.0), (16.0, 9.0), (8.0, 9.0)], ic.highlight, ink);
}
"export" => {
pen.poly(&[(4.0, 10.0), (4.0, 20.0), (16.0, 20.0), (16.0, 10.0)], pen.fill, ink);
@@ -572,7 +578,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
}
"zoom" => {
pen.circle(10.0, 10.0, 6.0, Color32::WHITE, ink);
pen.circle(10.0, 10.0, 6.0, ic.highlight, ink);
pen.line(&[(14.5, 14.5), (20.0, 20.0)], ink);
}
"perspective" => {
@@ -590,13 +596,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.iso_box(2.0, 13.0, 9.0, 6.0, 6.0);
pen.iso_box(9.0, 6.0, 5.0, 4.0, 4.0);
}
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], Color32::from_rgb(236, 200, 110), ink),
"folder" => pen.poly(&[(3.0, 7.0), (9.0, 7.0), (11.0, 9.0), (21.0, 9.0), (21.0, 19.0), (3.0, 19.0)], ic.folder, ink),
"origin" => {
pen.arrow((6.0, 18.0), (20.0, 18.0), Color32::from_rgb(210, 60, 60));
pen.arrow((6.0, 18.0), (6.0, 4.0), Color32::from_rgb(60, 100, 220));
pen.arrow((6.0, 18.0), (15.0, 9.0), Color32::from_rgb(60, 160, 60));
pen.arrow((6.0, 18.0), (20.0, 18.0), ic.axes[0]);
pen.arrow((6.0, 18.0), (6.0, 4.0), ic.axes[2]);
pen.arrow((6.0, 18.0), (15.0, 9.0), ic.axes[1]);
}
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], Color32::from_rgb(244, 214, 150), ink),
"plane" => pen.poly(&[(3.0, 15.0), (12.0, 8.0), (21.0, 11.0), (12.0, 18.0)], ic.plane, ink),
"axis" => {
pen.line(&[(4.0, 20.0), (20.0, 4.0)], ink);
pen.circle(4.0, 20.0, 2.0, ink, ink);
@@ -825,8 +831,8 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
}
"accessibility" => {
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], Color32::from_rgb(70, 180, 90), ink);
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], Color32::from_rgb(220, 70, 60), Color32::TRANSPARENT);
pen.poly(&[(2.0, 20.0), (22.0, 20.0), (22.0, 15.0), (2.0, 15.0)], ic.access[0], ink);
pen.poly(&[(8.0, 15.0), (16.0, 15.0), (16.0, 17.5), (8.0, 17.5)], ic.access[1], Color32::TRANSPARENT);
pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 12.5), (6.0, 12.5)], pen.fill, ink);
pen.arrow((4.0, 2.0), (4.0, 13.0), a);
pen.arrow((20.0, 2.0), (20.0, 13.0), a);
@@ -834,10 +840,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"environment_map" => {
let pts: Vec<Pos2> =
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
p.add(Shape::convex_polygon(pts, Color32::from_rgb(190, 205, 230), Stroke::new(pen.w, ink)));
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], Color32::from_rgb(60, 62, 70), Color32::TRANSPARENT);
pen.line(&[(3.2, 12.6), (20.8, 12.6)], Color32::WHITE);
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), Color32::WHITE);
p.add(Shape::convex_polygon(pts, ic.sky, Stroke::new(pen.w, ink)));
pen.poly(&[(3.6, 13.0), (20.4, 13.0), (18.5, 17.5), (5.5, 17.5)], ic.floor, Color32::TRANSPARENT);
pen.line(&[(3.2, 12.6), (20.8, 12.6)], ic.highlight);
p.circle_filled(pen.at(9.0, 8.0), 2.2 * pen.s(), ic.highlight);
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
}
"zebra" | "draft_analysis" | "curvature_map" => {
@@ -845,27 +851,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
(0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 9.0 * t.cos(), 12.0 + 9.0 * t.sin())).collect();
match name {
"zebra" => {
p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, ink)));
p.add(Shape::convex_polygon(pts, ic.highlight, Stroke::new(pen.w, ink)));
for k in 0..3 {
let y = 6.5 + k as f32 * 4.5;
let h = (81.0 - (y + 1.0 - 12.0) * (y + 1.0 - 12.0)).max(0.0).sqrt();
pen.poly(
&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)],
Color32::from_gray(30),
Color32::TRANSPARENT,
);
pen.poly(&[(12.0 - h, y), (12.0 + h, y), (12.0 + h, y + 2.2), (12.0 - h, y + 2.2)], ic.stripe, Color32::TRANSPARENT);
}
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
}
"draft_analysis" => {
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], Color32::from_rgb(70, 180, 90), ink);
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], Color32::from_rgb(235, 200, 50), Color32::TRANSPARENT);
pen.poly(&[(4.0, 20.0), (20.0, 20.0), (17.0, 5.0), (7.0, 5.0)], ic.draft[0], ink);
pen.poly(&[(7.0, 5.0), (17.0, 5.0), (16.0, 9.0), (8.0, 9.0)], ic.draft[1], Color32::TRANSPARENT);
pen.arrow((12.0, 16.0), (12.0, 1.5), a);
}
_ => {
p.add(Shape::convex_polygon(pts, Color32::from_rgb(60, 110, 230), Stroke::new(pen.w, ink)));
pen.circle(14.0, 10.0, 5.0, Color32::from_rgb(70, 190, 90), Color32::TRANSPARENT);
pen.circle(15.5, 8.5, 2.2, Color32::from_rgb(225, 60, 45), Color32::TRANSPARENT);
p.add(Shape::convex_polygon(pts, ic.curvature[0], Stroke::new(pen.w, ink)));
pen.circle(14.0, 10.0, 5.0, ic.curvature[1], Color32::TRANSPARENT);
pen.circle(15.5, 8.5, 2.2, ic.curvature[2], Color32::TRANSPARENT);
}
}
}
@@ -983,13 +985,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"patch" => {
// A wire loop with a surface spanned across it.
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], SHEET, ink);
pen.poly(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0)], ic.sheet, ink);
pen.line(&[(4.0, 16.0), (9.0, 6.0), (19.0, 5.0), (20.0, 15.0), (12.0, 20.0), (4.0, 16.0)], a);
}
"stitch" => {
// Two surface patches sewn along their shared edge.
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], SHEET, ink);
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], SHEET_2, ink);
pen.poly(&[(3.0, 18.0), (11.0, 14.0), (11.0, 4.0), (3.0, 8.0)], ic.sheet, ink);
pen.poly(&[(13.0, 14.0), (21.0, 18.0), (21.0, 8.0), (13.0, 4.0)], ic.sheet_2, ink);
for y in [6.0, 9.0, 12.0] {
pen.line(&[(10.0, y), (14.0, y + 1.5)], a);
}
@@ -999,7 +1001,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
let f = [(2.0, 12.5), (7.0, 9.5), (12.0, 15.5), (17.0, 12.5)];
let b = [(6.4, 8.9), (11.4, 5.9), (16.4, 11.9), (21.4, 8.9)];
let d = f.map(|(x, y)| (x, y + 6.0));
pen.band(&bezier(b, 16), &bezier(f, 16), SHEET);
pen.band(&bezier(b, 16), &bezier(f, 16), ic.sheet);
pen.band(&bezier(f, 16), &bezier(d, 16), pen.fill.gamma_multiply(0.85));
pen.poly(&[(17.0, 12.5), (21.4, 8.9), (21.4, 14.9), (17.0, 18.5)], pen.fill.gamma_multiply(0.7), ink);
pen.line(&[(2.0, 12.5), (2.0, 18.5)], ink);
@@ -1042,7 +1044,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// An open profile (accent) pulled up into a sheet.
let top = [(4.0, 11.0), (8.0, 8.0), (13.0, 14.0), (20.0, 10.0)];
let bottom = [(4.0, 20.0), (8.0, 17.0), (13.0, 23.0), (20.0, 19.0)];
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
pen.line(&[(4.0, 11.0), (4.0, 20.0)], ink);
pen.line(&[(20.0, 10.0), (20.0, 19.0)], ink);
pen.curve(top, ink);
@@ -1053,13 +1055,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// An open profile (accent) turned about the axis into a hollow band.
let left = [(5.0, 6.0), (7.0, 10.0), (5.0, 14.0), (6.0, 18.0)];
let right = [(19.0, 6.0), (17.0, 10.0), (19.0, 14.0), (18.0, 18.0)];
pen.band(&bezier(left, 16), &bezier(right, 16), SHEET);
pen.band(&bezier(left, 16), &bezier(right, 16), ic.sheet);
let front = ellipse(12.0, 18.0, 6.0, 2.2, 0.0, std::f32::consts::PI, 16);
pen.poly(&front, SHEET, Color32::TRANSPARENT);
pen.poly(&front, ic.sheet, Color32::TRANSPARENT);
// A closed fill takes each point once: a repeated end point spikes `poly`'s edge.
let mut opening = ellipse(12.0, 6.0, 7.0, 2.2, 0.0, std::f32::consts::TAU, 32);
opening.pop();
pen.poly(&opening, SHEET_LIGHT, ink);
pen.poly(&opening, ic.sheet_light, ink);
pen.line(&front, ink);
pen.curve(left, ink);
pen.line(&[(12.0, 1.5), (12.0, 3.8)], ink);
@@ -1070,7 +1072,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A straight profile (accent) carried along a curved path: a ribbon.
let near = [(4.0, 13.0), (8.0, 5.0), (14.0, 5.0), (20.0, 9.0)];
let far = [(4.0, 20.0), (8.0, 12.0), (14.0, 12.0), (20.0, 16.0)];
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet);
pen.curve(near, ink);
pen.curve(far, ink);
pen.line(&[(4.0, 13.0), (4.0, 20.0)], a);
@@ -1080,7 +1082,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// Two open sections (accent) joined by a sheet.
let top = [(5.0, 6.0), (9.0, 3.0), (14.0, 8.0), (19.0, 5.0)];
let bottom = [(3.0, 19.0), (8.0, 15.0), (14.0, 22.0), (21.0, 18.0)];
pen.band(&bezier(top, 16), &bezier(bottom, 16), SHEET);
pen.band(&bezier(top, 16), &bezier(bottom, 16), ic.sheet);
pen.line(&[(5.0, 6.0), (3.0, 19.0)], ink);
pen.line(&[(19.0, 5.0), (21.0, 18.0)], ink);
pen.curve(top, a);
@@ -1090,7 +1092,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A wavy sheet and its offset copy (dashed), the distance shown by the arrow.
let back = [(6.0, 14.0), (10.0, 11.0), (15.0, 17.0), (21.0, 14.0)];
let front = [(3.0, 20.0), (7.0, 17.0), (12.0, 23.0), (18.0, 20.0)];
pen.band(&bezier(back, 16), &bezier(front, 16), SHEET);
pen.band(&bezier(back, 16), &bezier(front, 16), ic.sheet);
pen.curve(back, ink);
pen.curve(front, ink);
pen.line(&[(6.0, 14.0), (3.0, 20.0)], ink);
@@ -1104,7 +1106,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"ruled" => {
// A strip hanging off a block's edge (accent), ruled by straight lines.
pen.poly(&[(2.0, 12.0), (12.0, 12.0), (12.0, 22.0), (2.0, 22.0)], pen.fill, ink);
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], SHEET, ink);
pen.poly(&[(12.0, 12.0), (21.0, 4.0), (21.0, 14.0), (12.0, 22.0)], ic.sheet, ink);
pen.line(&[(15.0, 9.3), (15.0, 19.3)], a);
pen.line(&[(18.0, 6.7), (18.0, 16.7)], a);
pen.line(&[(12.0, 12.0), (12.0, 22.0)], a);
@@ -1140,13 +1142,13 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
})
.collect();
for (lo, hi) in rows.iter().zip(rows.iter().skip(1)) {
pen.band(lo, hi, SHEET);
pen.band(lo, hi, ic.sheet);
}
for j in [n / 3, 2 * n / 3] {
if let Some(r) = rows.get(j) {
pen.line(r, SHEET_LINE);
pen.line(r, ic.sheet_line);
}
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), SHEET_LINE);
pen.line(&rows.iter().filter_map(|r| r.get(j).copied()).collect::<Vec<_>>(), ic.sheet_line);
}
for c in [left, top, right, bottom] {
pen.curve(c, a);
@@ -1160,29 +1162,29 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// leaves both edges tangent.
let near = [(13.0, 20.0), (8.5, 20.0), (10.5, 11.0), (6.0, 11.0)];
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], SHEET, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
pen.poly(&[(2.0, 11.0), (6.0, 11.0), (10.4, 7.4), (6.4, 7.4)], ic.sheet, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
pen.curve(far, a);
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], SHEET, ink);
pen.poly(&[(13.0, 20.0), (17.0, 20.0), (21.4, 16.4), (17.4, 16.4)], ic.sheet, ink);
pen.curve(near, a);
}
"untrim" => {
// A trimmed sheet whose hole (dashed, accent) is filled back in.
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], SHEET, ink);
pen.poly(&[(3.0, 17.0), (9.0, 5.0), (21.0, 7.0), (16.0, 20.0)], ic.sheet, ink);
let hole = ellipse(12.2, 12.3, 4.2, 3.2, 0.0, std::f32::consts::TAU, 32);
pen.poly(hole.get(1..).unwrap_or_default(), SHEET_LIGHT, Color32::TRANSPARENT);
pen.poly(hole.get(1..).unwrap_or_default(), ic.sheet_light, Color32::TRANSPARENT);
pen.dashed(&hole, a);
}
"unstitch" => {
// Two sheets pulled apart from their shared edge.
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], SHEET, ink);
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], SHEET_2, ink);
pen.poly(&[(2.0, 18.0), (9.0, 14.0), (9.0, 4.0), (2.0, 8.0)], ic.sheet, ink);
pen.poly(&[(15.0, 14.0), (22.0, 18.0), (22.0, 8.0), (15.0, 4.0)], ic.sheet_2, ink);
pen.arrow((10.8, 11.0), (7.0, 11.0), a);
pen.arrow((13.2, 11.0), (17.0, 11.0), a);
}
"reverse_normal" => {
// A sheet with its old normal (faded) and the flipped one (accent).
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], SHEET, ink);
pen.poly(&[(2.0, 14.0), (14.0, 10.0), (22.0, 13.0), (10.0, 18.0)], ic.sheet, ink);
pen.arrow((12.0, 14.0), (12.0, 3.5), ink.gamma_multiply(0.5));
pen.arrow((12.0, 14.0), (12.0, 22.5), a);
}
@@ -1190,10 +1192,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// A wall sheet and a floor sheet joined by a round strip (accent edges).
let near = [(3.0, 16.0), (3.0, 18.8), (5.2, 21.0), (8.0, 21.0)];
let far = near.map(|(x, y)| (x + 4.4, y - 3.6));
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], SHEET_2, ink);
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], SHEET, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), SHEET_LIGHT);
pen.line(&[(4.5, 19.5), (8.9, 15.9)], SHEET_LINE);
pen.poly(&[(3.0, 16.0), (3.0, 8.0), (7.4, 4.4), (7.4, 12.4)], ic.sheet_2, ink);
pen.poly(&[(8.0, 21.0), (16.0, 21.0), (20.4, 17.4), (12.4, 17.4)], ic.sheet, ink);
pen.band(&bezier(near, 16), &bezier(far, 16), ic.sheet_light);
pen.line(&[(4.5, 19.5), (8.9, 15.9)], ic.sheet_line);
pen.curve(far, a);
pen.curve(near, a);
}
@@ -1209,11 +1211,11 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
let h = 0.8 + 4.2 * i as f32 / 10.0;
let tip = (x + (y1 - y) / len * h, y - (x1 - x) / len * h);
if i % 2 == 0 {
pen.line(&[(*x, *y), tip], SHEET_LINE);
pen.line(&[(*x, *y), tip], ic.sheet_line);
}
tips.push(tip);
}
pen.line(&tips, SHEET_LINE);
pen.line(&tips, ic.sheet_line);
pen.curve(c, a);
pen.dot(12.0, 13.0, a);
}
@@ -1256,7 +1258,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
}
"interference" => {
pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], Color32::from_rgb(225, 70, 60), Color32::from_rgb(225, 70, 60));
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference);
pen.closed(&[(9.0, 10.0), (21.0, 10.0), (21.0, 20.0), (9.0, 20.0)], ink);
}
// Sheet metal: thin plates (fill), the made or changed part in the accent.
@@ -1283,7 +1285,7 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
"fold" => {
pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink);
pen.line(&[(12.0, 18.0), (19.0, 6.0)], a);
pen.line(&[(9.0, 21.0), (15.0, 15.0)], Color32::from_rgb(130, 140, 160));
pen.line(&[(9.0, 21.0), (15.0, 15.0)], ic.fold);
pen.arc(12.0, 18.0, 6.0, 0.2, 1.0, a);
pen.arrow((18.0, 16.0), (17.0, 12.5), a);
}
@@ -1358,16 +1360,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
// Appearance: a shaded ball with three paint dabs.
"appearance" => {
pen.circle(10.0, 13.0, 7.5, pen.fill, ink);
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, Color32::WHITE);
pen.circle(18.0, 5.0, 2.6, Color32::from_rgb(220, 70, 60), ink);
pen.circle(20.5, 11.0, 2.6, Color32::from_rgb(70, 150, 230), ink);
pen.circle(19.0, 17.5, 2.6, Color32::from_rgb(240, 200, 60), ink);
pen.arc(10.0, 13.0, 4.5, 1.8, 3.2, ic.highlight);
pen.circle(18.0, 5.0, 2.6, ic.paint[0], ink);
pen.circle(20.5, 11.0, 2.6, ic.paint[1], ink);
pen.circle(19.0, 17.5, 2.6, ic.paint[2], ink);
}
"warning" => {
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], Color32::from_rgb(250, 200, 60), ink);
pen.poly(&[(12.0, 3.0), (21.0, 20.0), (3.0, 20.0)], ic.warning, ink);
pen.line(&[(12.0, 9.0), (12.0, 14.0)], ink);
pen.dot(12.0, 17.0, ink);
}
// A failed feature: a disc with a cross, a different shape from the warning's triangle, so
// it reads without colour (#972). Drawn in the caller's colours only.
"error" => {
pen.circle(12.0, 12.0, 10.0, pen.fill, ink);
pen.line(&[(8.0, 8.0), (16.0, 16.0)], ink);
pen.line(&[(16.0, 8.0), (8.0, 16.0)], ink);
}
_ => {
pen.poly(&[(5.0, 5.0), (19.0, 5.0), (19.0, 19.0), (5.0, 19.0)], pen.fill, ink);
}
+6 -5
View File
@@ -5,8 +5,9 @@ use egui::{Color32, Pos2, Stroke, vec2};
pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: SnapKind, col: Color32, tag: Option<&str>) {
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
let white = Stroke::new(1.5, Color32::WHITE);
let ic = Tokens::get().icons;
let halo = Stroke::new(4.5, ic.marker_halo);
let white = Stroke::new(1.5, ic.highlight);
if let Some(tip) = tip {
let d = (tip - c).normalized();
let n = vec2(-d.y, d.x);
@@ -30,7 +31,7 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
}
}
};
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
painter.add(shape(ic.marker_halo, Stroke::NONE, 4.0));
painter.add(shape(col, Stroke::NONE, 0.0));
match kind {
SnapKind::FaceCenter => {
@@ -38,8 +39,8 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option<Pos2>, kind: Sn
}
SnapKind::CircleCenter => {
painter.circle_stroke(c, 7.0, white);
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, ic.highlight));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, ic.highlight));
}
SnapKind::EdgeMid | SnapKind::Vertex => {
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
+3 -4
View File
@@ -151,13 +151,12 @@ pub(crate) fn curved_arrow(painter: &egui::Painter, at: Pos2, clockwise: bool, h
pub(crate) const AXIS_RADIUS: f32 = 11.0;
pub(crate) fn axis_tip(painter: &egui::Painter, c: Pos2, name: &str, d: &Vec3, at: Pos2, depth: f64, hovered: bool, t: Tokens) {
let col = if d.x != 0.0 {
solidcraft_engine::view::colors::AXIS_X
t.triad[0]
} else if d.y != 0.0 {
solidcraft_engine::view::colors::AXIS_Y
t.triad[1]
} else {
solidcraft_engine::view::colors::AXIS_Z
t.triad[2]
};
let col = Color32::from_rgb(col.0, col.1, col.2);
let positive = !name.starts_with('-');
let ink = if depth < -0.01 { col.gamma_multiply(0.55) } else { col };
painter.line_segment([c, at], Stroke::new(if positive { 2.0 } else { 1.0 }, ink));
+10 -6
View File
@@ -1,14 +1,18 @@
//! Original agent pointer and label. Animation stays with the UI controller.
use egui::{Color32, FontId, Pos2, Shape, Stroke, vec2};
pub(crate) const AGENT: Color32 = Color32::from_rgb(168, 85, 247);
/// The agent pointer's colour (a theme token, so a colour vision mode can reach it).
pub(crate) fn agent() -> Color32 {
crate::theme::Tokens::get().icons.agent
}
pub(crate) fn paint(painter: &egui::Painter, p: Pos2) {
let ic = crate::theme::Tokens::get().icons;
// An arrow pointer, tip at `p`.
let pts: Vec<Pos2> =
[(0.0, 0.0), (0.0, 17.0), (4.5, 13.0), (7.5, 20.0), (10.5, 18.8), (7.5, 12.0), (13.0, 12.0)].iter().map(|(x, y)| p + vec2(*x, *y)).collect();
painter.add(Shape::convex_polygon(pts.clone(), AGENT, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, Color32::WHITE)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), Color32::WHITE);
painter.add(Shape::convex_polygon(pts.clone(), ic.agent, Stroke::NONE));
painter.add(Shape::closed_line(pts, Stroke::new(1.5, ic.highlight)));
let label = painter.layout_no_wrap("Agent".into(), FontId::proportional(11.0), ic.highlight);
let r = egui::Rect::from_min_size(p + vec2(15.0, 18.0), label.size() + vec2(10.0, 4.0));
painter.rect_filled(r, 4.0, AGENT);
painter.galley(r.min + vec2(5.0, 2.0), label, Color32::WHITE);
painter.rect_filled(r, 4.0, ic.agent);
painter.galley(r.min + vec2(5.0, 2.0), label, ic.highlight);
}
+371 -1
View File
@@ -4,12 +4,19 @@
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Unten den GitHub-Entwurf öffnen. Titel und Bericht werden vorausgefüllt.",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Screenshot anhängen: kopieren und einfügen oder als PNG speichern und hochladen.",
"3. Click Submit new issue on GitHub to send your report.": "3. Auf GitHub auf Submit new issue klicken, um den Bericht zu senden.",
"nav.touchpad.label": "Touchpad",
"nav.touchpad.help": "Umschalt gedrückt halten und den Zeiger zum Verschieben bewegen; mit Alt/Option drehen. Keine Maustaste nötig. Vertikales Scrollen zoomt.",
"nav.touchpad.orbit": "Drehen (Alt/Option halten und bewegen; ohne Maustaste)",
"nav.touchpad.pan": "Verschieben (Umschalt halten und bewegen; ohne Maustaste)",
"nav.touchpad.zoom": "Zoom (vertikal scrollen oder Finger spreizen/zusammenziehen)",
"nav.touchpad.scroll": "Touchpad verwendet vertikales Scrollen zum Zoomen. Die Zwei-Finger-Verschiebung bleibt für andere Vorgaben gespeichert.",
"browser.body_name": "Körper {number}",
"browser.component_name": "Komponente {number}",
"browser.parameters": "Parameter ({count})",
"browser.precision": "Genauigkeit: {precision}",
"browser.sketch_name": "Skizze {number}",
"browser.units": "Einheiten: {units}",
"dialog.computing": "Wird berechnet… {seconds} s",
"dialog.custom_axis": "↖ Eigene Achse",
"dialog.edit_title": "{title} bearbeiten",
"dialog.select": "↖ Auswählen",
@@ -19,6 +26,31 @@
"dialog.selection_guidance": "Wählen Sie eine Eingabe aus, um sie zu ändern. Klicken Sie auf ausgewählte Objekte, um sie zu entfernen; Strg/Cmd-Klick schaltet sie ebenfalls um. Prüfen Sie die Vorschau und drücken Sie dann OK.",
"document.save_changes": "Änderungen in {name} speichern?",
"document.unsaved_changes": "{name} enthält ungespeicherte Änderungen. Wenn Sie nicht speichern, gehen sie verloren.",
"home.documentation": "Dokumentation",
"home.in_this_browser": "in diesem Browser",
"home.missing": "fehlt",
"home.open": "Öffnen…",
"home.opened_days_ago": "Vor {count} Tag(en) geöffnet",
"home.opened_hours_ago": "Vor {count} Std. geöffnet",
"home.opened_just_now": "Gerade eben geöffnet",
"home.opened_minutes_ago": "Vor {count} Min. geöffnet",
"home.opening": "wird geöffnet…",
"home.recent": "Zuletzt verwendet",
"home.report_issue": "Problem melden",
"home.samples": "Beispiele",
"home.whats_new": "Neuigkeiten",
"sample.bracket.about": "Grundflansch mit Kantenflanschen und einer Umkantung nach einer 2-mm-Blechregel",
"sample.bracket.name": "Blechwinkel",
"sample.enclosure.about": "Ausgehöhlter Block mit Lippe, Schraubenerhöhungen, Auflage und Rastverbindung",
"sample.enclosure.name": "Kunststoffgehäuse",
"sample.flange.about": "Nabe und Flansch mit einem parametergesteuerten radialen Muster",
"sample.flange.name": "Schraubflansch",
"sample.hinge.about": "Zwei Komponenten und ein Drehgelenk, auf 60° angetrieben",
"sample.hinge.name": "Scharnierbaugruppe",
"sample.plate.about": "Parametrische Platte mit Vollrundungen, Bohrungen und einer aufgebohrten Erhöhung",
"sample.plate.name": "Beispielplatte",
"sample.vents.about": "Langlöcher in einem Rechteckmuster: Zeilen, Spalten und Abstand sind Parameter",
"sample.vents.name": "Lüftungsplatte",
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
"settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).",
"settings.msaa_unsupported": "Von diesem Grafikgerät nicht unterstützt",
@@ -34,6 +66,147 @@
"ui.active_component": "Aktive Komponente",
"ui.agent_cursor": "Agent-Cursor",
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
"ui.agent_chat": "Agenten-Chat",
"ui.agent_chat_command": "Agentenbefehl",
"ui.agent_chat_desktop_only": "Der Agenten-Chat braucht die Desktop-App.",
"ui.agent_chat_side": "Seite des Agenten-Chats",
"ui.agent_chat_left": "Links",
"ui.agent_chat_right": "Rechts",
"ui.agent_chat_turn_limit": "Änderungen pro Nachricht",
"ui.agent_chat_privacy_note": "SolidCraft sendet nichts irgendwohin: Der Agent, den Sie starten, spricht unter Ihrem eigenen Konto mit seinem eigenen Dienst. SolidCraft sieht und speichert nie einen Schlüssel oder ein Passwort.",
"ui.agent_chat_connect": "Verbinden",
"ui.agent_chat_stop": "Beenden",
"ui.agent_chat_stop_tip": "Den Agenten und alles, was er gestartet hat, beenden",
"ui.agent_chat_close": "Schließen",
"ui.agent_chat_minimize": "Zu einer Leiste minimieren",
"ui.agent_chat_dock_left": "Links andocken",
"ui.agent_chat_dock_right": "Rechts andocken",
"ui.agent_chat_send": "Senden",
"ui.agent_chat_cancel": "Abbrechen",
"ui.agent_chat_refresh": "Aktualisieren",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_custom_hint": "Jedes Programm, das das Agent Client Protocol über stdin und stdout spricht. Es läuft ohne Shell.",
"ui.agent_chat_not_installed": "Nicht installiert",
"ui.agent_chat_none_found": "Kein Coding-Agent gefunden. Installieren Sie einen davon und klicken Sie dann auf Aktualisieren:",
"ui.agent_chat_detecting": "Suche nach Agenten…",
"ui.agent_chat_signed_in": "angemeldet",
"ui.agent_chat_key_in_env": "Schlüssel gesetzt",
"ui.agent_chat_not_signed_in": "nicht angemeldet",
"ui.agent_chat_state_off": "Nicht verbunden",
"ui.agent_chat_state_starting": "Startet…",
"ui.agent_chat_state_ready": "Bereit",
"ui.agent_chat_state_busy": "Arbeitet…",
"ui.agent_chat_state_auth": "Anmeldung nötig",
"ui.agent_chat_state_exited": "Agent beendet",
"ui.agent_chat_changes_of": "{n}/{limit} Änderungen",
"ui.agent_chat_auth_title": "Beim Agenten anmelden",
"ui.agent_chat_auth_needed": "Der Agent bittet Sie, sich anzumelden. Sie melden sich beim Agenten selbst an; SolidCraft sieht Ihr Passwort oder Ihre Schlüssel nie.",
"ui.agent_chat_auth_none": "Der Agent bietet hier keine Anmeldung an: Melden Sie sich so an, wie es seine Dokumentation beschreibt.",
"ui.agent_chat_auth_terminal": "Führen Sie dies in einem Terminal aus und klicken Sie dann erneut auf Verbinden:",
"ui.agent_chat_auth_agent": "Mit {name} anmelden",
"ui.agent_chat_auth_env": "Setzen Sie {vars} in Ihrer Umgebung und starten Sie SolidCraft neu.",
"ui.agent_chat_auth_retry": "Ich bin angemeldet: erneut verbinden",
"ui.agent_chat_copy": "Kopieren",
"ui.agent_chat_permission": "Der Agent möchte ausführen: {title}",
"ui.agent_chat_answered": "Antwort: {option}",
"ui.agent_chat_approval": "Darf der Agent das tun?",
"ui.agent_chat_allow": "Erlauben",
"ui.agent_chat_deny": "Ablehnen",
"ui.agent_chat_allowed": "Erlaubt",
"ui.agent_chat_denied": "Abgelehnt",
"ui.agent_chat_changes": "Änderungen: {n}, ein Rückgängig-Schritt",
"ui.agent_chat_undo_changes": "Diese Änderungen rückgängig machen",
"ui.agent_chat_undone": "(rückgängig gemacht)",
"ui.agent_chat_undo_label": "Agenten-Chat: {message}",
"ui.agent_chat_limit": "Der Agent hat die Grenze von {limit} Änderungen für eine Nachricht erreicht. Senden Sie eine weitere Nachricht, damit er weitermacht.",
"ui.agent_chat_refused": "Für den Agenten nicht erlaubt: {reason}",
"ui.agent_chat_wait_turn": "Der Agent arbeitet noch an der letzten Nachricht: Warten Sie oder klicken Sie auf Abbrechen.",
"ui.agent_chat_placeholder": "Bitten Sie den Agenten, das Design zu ändern…",
"ui.agent_chat_attach_view": "Ansicht anhängen",
"ui.agent_chat_attach_view_tip": "Ein Bild der aktuellen Ansicht mit der Nachricht senden",
"ui.agent_chat_no_images": "Dieser Agent nimmt keine Bilder an",
"ui.agent_chat_view_attached": "Ansicht angehängt",
"ui.agent_chat_no_cli": "solidcraft-cli wurde neben der App nicht gefunden, daher kann der Agent das Design nicht erreichen.",
"ui.agent_chat_no_control": "Der Endpunkt des Agenten-Chats ist nicht gestartet, daher kann der Agent das Design nicht erreichen.",
"ui.agent_chat_exited": "Der Agent wurde beendet.",
"ui.agent_chat_failed_start": "Der Agent konnte nicht starten: {error}",
"ui.agent_chat_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von SolidCraft am geöffneten Design: Seine Änderungen erscheinen sofort und lassen sich pro Nachricht in einem Schritt rückgängig machen. Was er ohne Nachfrage darf, legen Sie unter Einstellungen > KI fest.",
"ui.agent_chat_privacy": "Nichts geht irgendwohin außer an den Agenten, den Sie unter Ihrem eigenen Konto ausführen.",
"ui.agent_chat_none_found_short": "Kein Agent gefunden",
"ui.ai_add": "Hinzufügen",
"ui.ai_add_agent": "Agent hinzufügen…",
"ui.ai_add_agent_title": "Agent hinzufügen",
"ui.ai_add_api": "OpenAI-kompatible API",
"ui.ai_add_custom": "Eigener Befehl",
"ui.ai_added": "hinzugefügt",
"ui.ai_add_installed": "Installierter Agent",
"ui.ai_add_installed_note": "Ein eigener Eintrag für einen installierten Agenten, zum Beispiel mit anderen Argumenten. Installierte Agenten erscheinen von selbst.",
"ui.ai_agent_mode_tip": "Der eigene Modus des Agenten: Seine eigenen Werkzeuge (Shell, Dateiänderungen) folgen ihm. Die Werkzeuge von SolidCraft folgen den Berechtigungen unter Einstellungen > KI.",
"ui.ai_agents": "Agenten",
"ui.ai_always_allow": "Immer erlauben",
"ui.ai_always_allowed": "Immer erlaubt",
"ui.ai_always_allowed_none": "Noch nichts ist immer erlaubt. Antworten „Immer erlauben“ im Chat erscheinen hier.",
"ui.ai_always_allow_tip": "{tool} für diesen Agenten ab jetzt ohne Nachfrage erlauben",
"ui.ai_auto_allowed": "Automatisch erlaubt: {title} ({why})",
"ui.ai_auto_mode": "Ihre Berechtigungseinstellung",
"ui.ai_auto_ours": "SolidCraft prüft seine eigenen Werkzeuge selbst",
"ui.ai_auto_remembered": "Sie haben „Immer erlauben“ gewählt",
"ui.ai_bad_url": "Geben Sie eine URL ein, die mit http:// oder https:// beginnt",
"ui.ai_base_url": "Basis-URL",
"ui.ai_button": "KI",
"ui.ai_copy_suffix": "Kopie",
"ui.ai_custom_url": "Eigene URL",
"ui.ai_default": "Standard",
"ui.ai_http_busy": "der Endpunkt ist ausgelastet oder am Limit; versuchen Sie es später",
"ui.ai_http_key": "der Endpunkt hat den Schlüssel abgelehnt: prüfen Sie ihn unter Einstellungen > KI",
"ui.ai_http_not_found": "Endpunkt oder Modell nicht gefunden: prüfen Sie Basis-URL und Modellnamen",
"ui.ai_key": "API-Schlüssel",
"ui.ai_keychain_error": "Der Schlüsselbund des Systems hat abgelehnt: {error}",
"ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert",
"ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.",
"ui.ai_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von SolidCraft. Für einen lokalen Server leer lassen.",
"ui.ai_mode_all": "Alles erlauben",
"ui.ai_mode_all_note": "Nichts fragt vorher. Absturzberichte bleiben tabu, und die Ordnergrenzen gelten weiter.",
"ui.ai_mode_all_warning": "Der Agent kann Ihr Design ohne Nachfrage speichern, überschreiben, exportieren und ersetzen. Nutzen Sie das nur mit einem Agenten, dem Sie vertrauen, und mit gespeicherten Designs.",
"ui.ai_mode_ask": "Jedes Mal fragen",
"ui.ai_mode_ask_note": "Jede Änderung am Design und jede Dateiaktion wartet auf Ihr Erlauben.",
"ui.ai_mode_edits": "Modelländerungen automatisch erlauben",
"ui.ai_mode_edits_note": "Modellierbefehle laufen ohne Nachfrage; Speichern, Öffnen, Exportieren und andere Datei- oder destruktive Aktionen fragen weiterhin.",
"ui.ai_mode_global": "Wie oben eingestellt",
"ui.ai_model": "Modell",
"ui.ai_modes_scope": "Diese Einstellungen gelten für die Werkzeuge von SolidCraft. Die eigenen Werkzeuge eines Agenten (seine Shell, seine Dateiänderungen) folgen seinem eigenen Modus, den Sie im Kopf des Chat-Panels wählen, wenn der Agent Modi hat.",
"ui.ai_name": "Name",
"ui.ai_need_command": "Geben Sie den Befehl ein, der den Agenten startet",
"ui.ai_need_model": "Geben Sie den Modellnamen ein",
"ui.ai_no_answer": "Der Agent hat nicht rechtzeitig geantwortet",
"ui.ai_no_http": "Dieser Build kann keine API-Endpunkte erreichen",
"ui.ai_no_keychain": "Der Schlüsselbund des Systems ist nicht verfügbar, daher kann kein Schlüssel gespeichert werden. Ein lokaler Server ohne Schlüssel funktioniert trotzdem.",
"ui.ai_panel": "Panel",
"ui.ai_permissions": "Berechtigungen",
"ui.ai_preset": "Vorlage",
"ui.ai_remove": "Entfernen",
"ui.ai_request_limit": "nach 24 Anfragen für eine Nachricht angehalten; senden Sie eine weitere Nachricht, um fortzufahren",
"ui.ai_reset": "Zurücksetzen",
"ui.ai_settings": "KI-Einstellungen…",
"ui.ai_show_features": "KI-Funktionen anzeigen",
"ui.ai_show_features_note": "Der Agenten-Chat: ein Panel, in dem ein Coding-Agent, den Sie schon nutzen, oder ein Modell-Endpunkt Ihrer Wahl am geöffneten Design arbeitet. Aus: keine KI-Schaltfläche, kein Kürzel, kein Menüeintrag, kein Panel, und nichts wird gesucht oder gestartet.",
"ui.ai_switch_tip": "Den Agenten für dieses Gespräch wechseln",
"ui.ai_test": "Testen",
"ui.ai_test_acp_ok": "Antwortet: {agent}",
"ui.ai_test_acp_tip": "Startet den Agenten kurz und bittet um eine Sitzung; beendet ihn wieder",
"ui.ai_test_api_tip": "Fragt den Endpunkt nach seinen Modellen",
"ui.ai_testing": "Teste…",
"ui.ai_test_no_model": "Erreicht: {count} Modelle, aber nicht {model}",
"ui.ai_test_ok": "Erreicht: {count} Modelle",
"ui.ai_toolbar_button": "Schaltfläche in der Werkzeugleiste anzeigen",
"ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.",
"ui.ai_tool_done": "fertig",
"ui.ai_tool_failed": "fehlgeschlagen",
"ui.ai_tool_running": "läuft",
"ui.ai_tool_unfold": "Klicken, um das Ergebnis zu zeigen",
"ui.ai_tool_waiting": "wartet",
"ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen",
"AI": "KI",
"ui.all": "Alle",
"ui.analysis": "Analyse",
"ui.angle": "Winkel",
@@ -42,6 +215,16 @@
"ui.animation": "Animation",
"ui.animation_speed": "Animationsgeschwindigkeit",
"ui.anti_aliasing": "Kantenglättung",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_off": "Aus (1×)",
"ui.supersampling_adaptive": "Nur bei ruhender Ansicht",
"settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.",
"ui.display_quality": "Darstellungsqualität",
"ui.display_quality_coarse": "Grob",
"ui.display_quality_normal": "Normal",
"ui.display_quality_fine": "Fein",
"ui.display_quality_ultra": "Ultra",
"settings.display_quality_note": "Feinere Stufen zeigen gekrümmte Flächen und Kanten glatter und brauchen länger zum Vernetzen. Nur die Ansicht ändert sich, nie das Design oder Exporte.",
"ui.apply": "Anwenden",
"ui.arc": "Kreisbogen",
"ui.assemble": "Zusammenbau",
@@ -68,10 +251,47 @@
"ui.clear_recent": "Zuletzt verwendet leeren",
"ui.clear_the_selection": "Auswahl aufheben",
"ui.close": "Schließen",
"ui.material_review.title": "Physikalische Materialdaten prüfen",
"ui.material_review.scope": "Nur Kandidat: nicht zugewiesen oder gespeichert. Numerische Prüfung ist keine technische Zertifizierung.",
"ui.material_review.input": "Materialkarte mit Schema 1 einfügen (JSON)",
"ui.material_review.review": "Karte prüfen",
"ui.material_review.limit": "Die Karte überschreitet die Eingabegrenze von 256 KiB.",
"ui.material_review.unknown": "Unbekannt",
"ui.material_review.properties": "Eigenschaften",
"ui.material_review.conditions": "Angegebene Bedingungen",
"ui.material_review.source": "Quelle",
"ui.material_review.axes": "Materiallokale Achsen; keine Körperausrichtung zugewiesen.",
"ui.material_review.derived": "Abgeleitete isotrope Moduln",
"ui.material_review.claims": "Quellen und Lizenzen sind ungeprüfte Angaben. Fehlende Werte bleiben unbekannt.",
"ui.material_review.density": "Dichte",
"ui.material_review.elasticity": "Elastizität",
"ui.material_review.tensile_strength": "Zugfestigkeit",
"ui.material_review.compressive_strength": "Druckfestigkeit",
"ui.material_review.yield_strength": "Streckgrenze",
"ui.material_review.shear_strength": "Scherfestigkeit",
"ui.material_review.thermal_conductivity": "Wärmeleitfähigkeit",
"ui.material_review.thermal_expansion": "Wärmeausdehnung",
"ui.material_review.specific_heat": "Spezifische Wärmekapazität",
"ui.material_review.moisture_unit": "kg Wasser / kg Trockenmaterial",
"ui.coil": "Schraube",
"ui.coincident": "Koinzident",
"ui.collapse": "Einklappen",
"ui.collinear": "Kollinear",
"ui.colour_vision": "Farbsehen",
"ui.colour_vision_deutan": "Deutan",
"ui.colour_vision_deutan_tip": "Bei schwachem oder fehlendem Grünsinn",
"ui.colour_vision_failed": "Fehler",
"ui.colour_vision_fixed": "Fixiert",
"ui.colour_vision_mode": "Modus",
"ui.colour_vision_note": "Ändert die Farben, die eine Bedeutung tragen: Skizzenstatus, Achsen, Biegungen, Analysen und Symbole. Wenn Sie die Farbfilter von Windows verwenden, lassen Sie dies auf „Aus“.",
"ui.colour_vision_off": "Aus",
"ui.colour_vision_off_tip": "Die eigenen Farben des Designs",
"ui.colour_vision_projected": "Projiziert",
"ui.colour_vision_protan": "Protan",
"ui.colour_vision_protan_tip": "Bei schwachem oder fehlendem Rotsinn",
"ui.colour_vision_sample": "Beispiel",
"ui.colour_vision_tritan": "Tritan",
"ui.colour_vision_tritan_tip": "Bei schwachem oder fehlendem Blausinn",
"ui.combine": "Kombinieren",
"ui.coming_later": "bald verfügbar",
"ui.concentric": "Konzentrisch",
@@ -265,6 +485,10 @@
"ui.quantity": "Anzahl",
"ui.quit": "Beenden",
"ui.radius": "Radius",
"ui.pull_direction": "Entformungsrichtung",
"ui.tool_direction": "Werkzeugrichtung",
"ui.reachable": "Erreichbar",
"ui.blocked": "Verdeckt",
"ui.radius_type": "Radius-Typ",
"ui.rectangle": "Rechteck",
"ui.rectangular_pattern": "Rechteckmuster",
@@ -276,9 +500,33 @@
"ui.revolve": "Drehen",
"ui.right": "Rechts",
"ui.save": "Speichern",
"ui.heading_save_mesh": "ALS NETZ SPEICHERN",
"ui.format": "Format",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF mit Flächenmarken",
"ui.send_to_slicer": "An Slicer senden",
"ui.slicer": "Slicer",
"ui.custom_program": "Benutzerdefiniert…",
"ui.no_slicer_found": "Kein Slicer gefunden",
"ui.send": "Senden",
"ui.custom_program_tip": "Ein Slicer-Programm zur Liste hinzufügen",
"ui.slicer_delete_tip": "{program}\nMit Entf aus der Liste entfernen",
"ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus",
"ui.save_mesh_no_slicer": "Kein Slicer gefunden: „Benutzerdefiniert…“ wählen und das Slicer-Programm angeben, oder „An Slicer senden“ ausschalten",
"ui.not_a_program": "{path} ist kein startbares Programm",
"ui.sent_to_slicer": "An {slicer} gesendet",
"ui.saved_to": "Gespeichert: {path}",
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
"ui.save_as": "Speichern unter",
"ui.save_as_ellipsis": "Speichern unter...",
"ui.scale": "Skalieren",
"ui.orientation": "Ausrichtung",
"ui.twist": "Verdrehung",
"ui.end_scale": "Endskalierung",
"ui.sweep_turn_with_path": "Mit Pfad drehen",
"ui.sweep_keep_as_drawn": "Wie gezeichnet",
"ui.sweep_follow_direction": "Richtung folgen",
"ui.section_analysis": "Querschnittsanalyse",
"ui.select": "Auswählen",
"ui.selection": "Auswahl",
@@ -366,6 +614,17 @@
"ui.pinToShortcuts": "An Verknüpfungen anheften",
"ui.pinToToolbar": "An Symbolleiste anheften",
"ui.properties": "Eigenschaften",
"ui.design_properties.project": "Projekt",
"ui.design_properties.part_number": "Teilenummer",
"ui.design_properties.revision": "Revision",
"ui.design_properties.author": "Autor",
"ui.design_properties.organization": "Organisation",
"ui.design_properties.description": "Beschreibung",
"ui.design_properties.license": "Lizenz",
"ui.design_properties.not_text": "Nicht als Text gespeichert; eine Eingabe hier ersetzt den Wert.",
"ui.design_properties.exports": "STEP-, IGES- und 3MF-Exporte übernehmen diese Angaben, wo das Format ein Feld dafür hat.",
"ui.design_properties.others": "Daten anderer Werkzeuge",
"ui.design_properties.others_note": "Bleiben unverändert in der Konstruktion und werden nie exportiert.",
"ui.removeSection": "Schnitt entfernen",
"ui.removeFromToolbar": "Aus der Symbolleiste entfernen",
"ui.repeat": "Wiederholen",
@@ -389,6 +648,9 @@
"ui.updateToCurrentView": "Auf aktuelle Ansicht aktualisieren",
"ui.zoom": "Zoomen",
"ui.command_done": "{command} fertig",
"drive.stopped_by_loop": "Angehalten bei {value}: die Schleife über {joint} lässt sich nicht weiter schließen",
"drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen",
"drive.unconnected": "Bewegt nichts: {part} ist mit nichts Festgesetztem verbunden",
"cmd.appearance_assign": "Erscheinungsbild",
"cmd.appearance_library": "Erscheinungsbild-Bibliothek",
"cmd.appearance_list": "Erscheinungsbilder auflisten",
@@ -409,6 +671,7 @@
"cmd.component_capture_position": "Position erfassen",
"cmd.component_create": "Neue Komponente",
"cmd.component_delete": "Komponente löschen",
"cmd.component_unlock": "Inhalt entsperren",
"cmd.component_from_bodies": "Komponenten aus Körpern erstellen",
"cmd.component_insert_design": "Konstruktion einfügen",
"cmd.component_links": "Verknüpfungen auflisten",
@@ -461,6 +724,9 @@
"cmd.doc_recovery_list": "Wiederherstellbare Konstruktionen",
"cmd.document_inspect": "Konstruktion prüfen",
"cmd.document_units": "Einheiten der Konstruktion ändern",
"cmd.document_metadata": "Konstruktionseigenschaften",
"cmd.document_metadata_set": "Konstruktionseigenschaften ändern",
"cmd.document_metadata_remove": "Konstruktionseigenschaft entfernen",
"cmd.edit_checkpoint": "Prüfpunkt",
"cmd.edit_restore_checkpoint": "Prüfpunkt wiederherstellen",
"cmd.engine_commands": "Befehle auflisten",
@@ -474,8 +740,10 @@
"cmd.file_insert_mesh": "Netz einfügen",
"cmd.file_insert_step": "STEP einfügen",
"cmd.file_save_mesh": "Als Netz speichern",
"cmd.file_slicers": "Installierte Slicer",
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
"cmd.inspect_compare": "Körper vergleichen",
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
"cmd.inspect_curvature_map": "Krümmungskartenanalyse",
"cmd.inspect_draft": "Auswinkelanalyse",
@@ -522,6 +790,7 @@
"cmd.parameters_delete": "Parameter löschen",
"cmd.parameters_export": "Parameter exportieren",
"cmd.parameters_favorite": "Favorisierter Parameter",
"cmd.parameters_move": "Parameter verschieben",
"cmd.parameters_graph": "Parametergraph",
"cmd.parameters_import": "Parameter importieren",
"cmd.parameters_list": "Parameter auflisten",
@@ -571,6 +840,8 @@
"cmd.sketch_constraint_fix": "Fixieren/Lösen",
"cmd.sketch_constraint_midpoint": "Mittelpunkt",
"cmd.sketch_construction": "Normal/Konstruktion",
"cmd.sketch_copy": "Skizzenelemente kopieren",
"cmd.sketch_cut": "Skizzenelemente ausschneiden",
"cmd.sketch_delete": "Skizzenentitäten löschen",
"cmd.sketch_dimension_text": "Maßtext verschieben",
"cmd.sketch_drag": "Skizzengeometrie ziehen",
@@ -595,6 +866,7 @@
"cmd.sketch_move_point": "Skizzenpunkt ziehen",
"cmd.sketch_offset_break": "Versatzverknüpfung trennen",
"cmd.sketch_options": "Skizzenpalettenoptionen",
"cmd.sketch_paste": "Skizzenelemente einfügen",
"cmd.sketch_point3d": "3D-Punkt",
"cmd.sketch_polygon_circumscribed": "Umgeschriebenes Polygon",
"cmd.sketch_polygon_edge": "Polygon über eine Kante",
@@ -668,9 +940,12 @@
"ui.heading_constraints": "RANDBEDINGUNGEN",
"ui.heading_utilities": "WERKZEUGE",
"BROWSER": "BROWSER",
"Bends": "Biegungen",
"Drive Joint": "Gelenk antreiben",
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
"Find in Browser": "Im Browser suchen",
"Copy Error Message": "Fehlermeldung kopieren",
"Copy Warning Message": "Warnmeldung kopieren",
"Report a bug — review a screenshot and open a GitHub draft": "Fehler melden — Screenshot prüfen und einen GitHub-Entwurf öffnen",
"Reset Toolbar": "Symbolleiste zurücksetzen",
"Select bodies (a face picks its body)": "Körper auswählen (eine Fläche wählt ihren Körper)",
@@ -721,6 +996,18 @@
"Profiles/Curves": "Profile/Kurven",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} ist verfügbar",
"install.menu": "App installieren…",
"install.button_tip": "SolidCraft als App installieren: eigenes Fenster, funktioniert auch offline",
"install.title": "SolidCraft installieren",
"install.steps_intro": "Safari installiert Web-Apps über seine eigenen Menüs:",
"install.home_screen_1": "Tippen Sie auf „Teilen“ (im Menü …, falls es nicht in der Symbolleiste ist).",
"install.home_screen_2": "Wählen Sie „Zum Home-Bildschirm“.",
"install.home_screen_3": "Tippen Sie auf „Hinzufügen“.",
"install.home_screen_after": "SolidCraft öffnet sich dann über sein Symbol auf dem Home-Bildschirm, ohne die Leisten von Safari, und funktioniert auch offline.",
"install.dock_1": "Wählen Sie Ablage › Zum Dock hinzufügen.",
"install.dock_2": "Klicken Sie auf „Hinzufügen“.",
"install.dock_after": "SolidCraft öffnet sich dann aus dem Dock in einem eigenen Fenster und funktioniert auch offline.",
"install.done": "Fertig",
"update.title": "Update verfügbar",
"update.available": "SolidCraft {version} ist verfügbar",
"update.you_have": "Installiert ist {version}.",
@@ -752,5 +1039,88 @@
"update.restart_later": "Später",
"update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.",
"update.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: SolidCraft in „Programme“ ziehen, die alte Version ersetzen und SolidCraft neu starten.",
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket."
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket.",
"cmd.render_settings_get": "Rendereinstellungen",
"cmd.render_settings_set": "Rendereinstellungen speichern",
"cmd.render_scene": "Renderszene prüfen",
"cmd.render_settings_apply": "Renderkamera anwenden",
"toolbar.render": "RENDERN",
"toolbar.render_scene": "SZENE",
"render.preview": "Integrierte Vorschau",
"render.preview_help": "Rasterdarstellung mit gemischter Transparenz. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.",
"render.setup_name": "Name des Setups",
"render.background": "Szenenhintergrund",
"render.perspective": "Perspektivische Kamera",
"render.fit": "Produkt einpassen",
"render.restore_camera": "Gespeicherte Kamera laden",
"render.save_setup": "Setup speichern",
"render.unsaved": "Ungespeicherte Vorschauänderungen",
"render.reload_settings": "Gespeicherte Einstellungen laden",
"render.unavailable_preview": "Dieses Setup verlangt ein nicht verfügbares Vorschauprofil.",
"render.use_preview": "Integrierte Vorschau verwenden",
"render.navigation_help": "Die Kamera bewegt sich wie im Konstruieren (Einstellungen › Navigation). Setup speichern übernimmt die aktuelle Kamera.",
"render.return_design": "Zurück zu Design",
"render.image_title": "Bild rendern",
"render.image_help": "CPU-Vorschaubild · PNG · angenäherte Transparenz",
"render.image_width": "Breite",
"render.image_height": "Höhe",
"render.image_transparent": "Transparenter Hintergrund",
"render.image_check": "Renderanfrage prüfen",
"render.image_supported": "Renderanfrage unterstützt; Warteschlange und Speicher werden beim Start erneut geprüft.",
"render.image_queued": "Warten auf Rendering",
"render.image_preparing": "Bild wird vorbereitet",
"render.image_running": "Bild wird gerendert",
"render.image_encoding": "PNG wird kodiert",
"render.image_cancelling": "Wird abgebrochen",
"render.image_cancelled": "Rendering abgebrochen",
"render.image_completed": "Bild bereit zum Export",
"render.image_failed": "Rendering fehlgeschlagen",
"render.image_unknown": "Renderstatus nicht verfügbar",
"render.image_reduce": "Kleineres Bild oder einfachere Szene versuchen.",
"render.image_too_large": "Über dem Bildlimit: 4096 Pixel pro Seite und 4.194.304 Pixel insgesamt.",
"render.image_use_largest": "Größte Größe mit diesem Seitenverhältnis verwenden:",
"render.image_cancel": "Rendering abbrechen",
"render.image_stale": "Dieses Bild verwendet die Szene vor den nachfolgenden Änderungen.",
"render.image_start": "Rendering starten",
"render.image_overwrite": "Vorhandene Datei ersetzen",
"render.image_export": "PNG exportieren",
"cmd.render_preflight": "Renderanfrage prüfen",
"cmd.render_start": "Bild rendern",
"cmd.render_status": "Renderfortschritt",
"cmd.render_cancel": "Rendering abbrechen",
"cmd.render_export": "PNG exportieren",
"toolbar.image": "BILD",
"appearance.surface_response": "Oberflächenverhalten",
"appearance.pbr_opaque": "Opakes PBR",
"appearance.metallic": "Metallanteil",
"appearance.roughness": "Rauheit",
"appearance.dielectric_ior": "Brechungsindex des Dielektrikums",
"appearance.pbr_help": "Diese Oberfläche in Render oder einem Produkt-PNG ansehen. Keine Brechung oder Transmission.",
"render.opaque_preview_help": "Opakes PBR und einfache Farben; Transparenz durch Blending. Brechung, HDRI und finales Rendering sind noch nicht verfügbar.",
"render.opaque_image_summary": "Vorschau-PNG · opakes PBR · Transparenz durch Blending",
"UNTRIM": "TRIMMEN AUFHEBEN",
"Face": "Fläche",
"Holes": "Löcher",
"Holes (edges on them)": "Löcher (ihre Kanten)",
"In place (surfaces)": "An Ort und Stelle (Flächen)",
"None": "Keine",
"Picked": "Ausgewählte",
"Restore outer boundary": "Äußere Begrenzung wiederherstellen",
"RULED SURFACE": "REGELFLÄCHE",
"Mitre": "Gehrung",
"Reference face": "Referenzfläche",
"Round": "Rund",
"Type": "Typ",
"joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.",
"joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.",
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist.",
"Circumference": "Umfang",
"Arc length": "Bogenlänge",
"Slot width": "Langlochbreite",
"Centre to centre": "Mitte zu Mitte",
"Overall length": "Gesamtlänge",
"Min": "Min",
"Max": "Max",
"Length": "Länge",
"Area": "Fläche"
}
+367 -1
View File
@@ -4,6 +4,12 @@
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Open GitHub draft below. Your title and report will be filled in.",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Attach your screenshot: copy and paste it, or save and upload the PNG.",
"3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.",
"nav.touchpad.label": "Touchpad",
"nav.touchpad.help": "Hold Shift and move the pointer to pan; hold Alt/Option and move it to orbit. No mouse button is needed. Vertical scroll zooms.",
"nav.touchpad.orbit": "Orbit (hold Alt/Option and move; no mouse button)",
"nav.touchpad.pan": "Pan (hold Shift and move; no mouse button)",
"nav.touchpad.zoom": "Zoom (vertical scroll or pinch)",
"nav.touchpad.scroll": "Touchpad uses vertical scroll to zoom. Your two-finger pan setting is kept for other presets.",
"browser.body_name": "Body{number}",
"browser.component_name": "Component{number}",
"browser.parameters": "Parameters ({count})",
@@ -30,6 +36,7 @@
"cmd.component_capture_position": "Capture Position",
"cmd.component_create": "New Component",
"cmd.component_delete": "Delete Component",
"cmd.component_unlock": "Unlock Contents",
"cmd.component_from_bodies": "Create Components from Bodies",
"cmd.component_insert_design": "Insert Design",
"cmd.component_links": "List Links",
@@ -82,6 +89,9 @@
"cmd.doc_recovery_list": "Recoverable Designs",
"cmd.document_inspect": "Inspect Design",
"cmd.document_units": "Change Design Units",
"cmd.document_metadata": "Design Properties",
"cmd.document_metadata_set": "Change Design Properties",
"cmd.document_metadata_remove": "Remove Design Property",
"cmd.edit_checkpoint": "Checkpoint",
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
"cmd.engine_commands": "List Commands",
@@ -95,8 +105,10 @@
"cmd.file_insert_mesh": "Insert Mesh",
"cmd.file_insert_step": "Insert STEP",
"cmd.file_save_mesh": "Save As Mesh",
"cmd.file_slicers": "Installed Slicers",
"cmd.inspect_accessibility": "Accessibility Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_compare": "Compare Bodies",
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
"cmd.inspect_curvature_map": "Curvature Map Analysis",
"cmd.inspect_draft": "Draft Analysis",
@@ -141,6 +153,7 @@
"cmd.parameters_delete": "Delete Parameter",
"cmd.parameters_export": "Export Parameters",
"cmd.parameters_favorite": "Favourite Parameter",
"cmd.parameters_move": "Move Parameter",
"cmd.parameters_graph": "Parameter Graph",
"cmd.parameters_import": "Import Parameters",
"cmd.parameters_list": "List Parameters",
@@ -190,6 +203,8 @@
"cmd.sketch_constraint_fix": "Fix/UnFix",
"cmd.sketch_constraint_midpoint": "MidPoint",
"cmd.sketch_construction": "Normal/Construction",
"cmd.sketch_copy": "Copy Sketch Entities",
"cmd.sketch_cut": "Cut Sketch Entities",
"cmd.sketch_delete": "Delete Sketch Entities",
"cmd.sketch_dimension_text": "Move Dimension Text",
"cmd.sketch_drag": "Drag Sketch Geometry",
@@ -214,6 +229,7 @@
"cmd.sketch_move_point": "Drag Sketch Point",
"cmd.sketch_offset_break": "Break Offset Association",
"cmd.sketch_options": "Sketch Palette Options",
"cmd.sketch_paste": "Paste Sketch Entities",
"cmd.sketch_point3d": "3D Point",
"cmd.sketch_polygon_circumscribed": "Circumscribed Polygon",
"cmd.sketch_polygon_edge": "Edge Polygon",
@@ -283,6 +299,7 @@
"cmd.view_list": "List Named Views",
"cmd.view_rename": "Rename Named View",
"cmd.view_save": "New Named View",
"dialog.computing": "Computing… {seconds} s",
"dialog.custom_axis": "↖ Custom Axis",
"dialog.edit_title": "EDIT {title}",
"dialog.select": "↖ Select",
@@ -292,6 +309,31 @@
"dialog.selection_guidance": "Select an input to change it. Click selected objects to remove them; Ctrl/Cmd-click also toggles them. Review the preview, then press OK.",
"document.save_changes": "Save changes to {name}?",
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
"home.documentation": "Documentation",
"home.in_this_browser": "in this browser",
"home.missing": "missing",
"home.open": "Open…",
"home.opened_days_ago": "Opened {count} days ago",
"home.opened_hours_ago": "Opened {count} h ago",
"home.opened_just_now": "Opened just now",
"home.opened_minutes_ago": "Opened {count} min ago",
"home.opening": "opening…",
"home.recent": "Recent",
"home.report_issue": "Report an Issue",
"home.samples": "Samples",
"home.whats_new": "What's new",
"sample.bracket.about": "Base flange with edge flanges and a hem on a 2 mm rule",
"sample.bracket.name": "Sheet Metal Bracket",
"sample.enclosure.about": "Shelled box with a lip, screw bosses, a rest and a snap fit",
"sample.enclosure.name": "Plastic Enclosure",
"sample.flange.about": "Hub and flange with a circular pattern driven by a parameter",
"sample.flange.name": "Bolted Flange",
"sample.hinge.about": "Two components and a revolute joint, driven to 60°",
"sample.hinge.name": "Hinge Assembly",
"sample.plate.about": "Parametric plate with fillets, holes and a bored boss",
"sample.plate.name": "Sample Plate",
"sample.vents.about": "Slots in a rectangular pattern: rows, columns and pitch are parameters",
"sample.vents.name": "Vent Plate",
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
"settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).",
"settings.msaa_unsupported": "Not supported by this graphics device",
@@ -309,6 +351,147 @@
"ui.active_component": "Active component",
"ui.agent_cursor": "Agent cursor",
"ui.agent_cursor_speed": "Agent cursor speed",
"ui.agent_chat": "Agent chat",
"ui.agent_chat_command": "Agent command",
"ui.agent_chat_desktop_only": "The agent chat needs the desktop app.",
"ui.agent_chat_side": "Agent chat side",
"ui.agent_chat_left": "Left",
"ui.agent_chat_right": "Right",
"ui.agent_chat_turn_limit": "Changes per message",
"ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.",
"ui.agent_chat_connect": "Connect",
"ui.agent_chat_stop": "Stop",
"ui.agent_chat_stop_tip": "Stop the agent and everything it started",
"ui.agent_chat_close": "Close",
"ui.agent_chat_minimize": "Minimise to a strip",
"ui.agent_chat_dock_left": "Dock on the left",
"ui.agent_chat_dock_right": "Dock on the right",
"ui.agent_chat_send": "Send",
"ui.agent_chat_cancel": "Cancel",
"ui.agent_chat_refresh": "Refresh",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_custom_hint": "Any program that speaks the Agent Client Protocol on stdin and stdout. No shell runs it.",
"ui.agent_chat_not_installed": "Not installed",
"ui.agent_chat_none_found": "No coding agent found. Install one of these, then press Refresh:",
"ui.agent_chat_detecting": "Looking for agents…",
"ui.agent_chat_signed_in": "signed in",
"ui.agent_chat_key_in_env": "key set",
"ui.agent_chat_not_signed_in": "not signed in",
"ui.agent_chat_state_off": "Not connected",
"ui.agent_chat_state_starting": "Starting…",
"ui.agent_chat_state_ready": "Ready",
"ui.agent_chat_state_busy": "Working…",
"ui.agent_chat_state_auth": "Sign-in needed",
"ui.agent_chat_state_exited": "Agent stopped",
"ui.agent_chat_changes_of": "{n}/{limit} changes",
"ui.agent_chat_auth_title": "Sign in to the agent",
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
"ui.agent_chat_auth_none": "The agent offers no sign-in method here: sign in the way its own documentation describes.",
"ui.agent_chat_auth_terminal": "Run this in a terminal, then press Connect again:",
"ui.agent_chat_auth_agent": "Sign in with {name}",
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
"ui.agent_chat_auth_retry": "I've signed in: connect again",
"ui.agent_chat_copy": "Copy",
"ui.agent_chat_permission": "The agent asks to run: {title}",
"ui.agent_chat_answered": "Answer: {option}",
"ui.agent_chat_approval": "Allow the agent to do this?",
"ui.agent_chat_allow": "Allow",
"ui.agent_chat_deny": "Deny",
"ui.agent_chat_allowed": "Allowed",
"ui.agent_chat_denied": "Denied",
"ui.agent_chat_changes": "Changes: {n}, one undo step",
"ui.agent_chat_undo_changes": "Undo these changes",
"ui.agent_chat_undone": "(undone)",
"ui.agent_chat_undo_label": "Agent chat: {message}",
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
"ui.agent_chat_refused": "Not allowed for the agent: {reason}",
"ui.agent_chat_wait_turn": "The agent is still working on the last message: wait for it or press Cancel.",
"ui.agent_chat_placeholder": "Ask the agent to change the design…",
"ui.agent_chat_attach_view": "Attach view",
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
"ui.agent_chat_no_images": "This agent doesn't take pictures",
"ui.agent_chat_view_attached": "View attached",
"ui.agent_chat_no_cli": "solidcraft-cli was not found next to the app, so the agent can't reach the design.",
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
"ui.agent_chat_exited": "The agent stopped.",
"ui.agent_chat_failed_start": "The agent could not start: {error}",
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
"ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.",
"ui.agent_chat_none_found_short": "No agent found",
"ui.ai_add": "Add",
"ui.ai_add_agent": "Add agent…",
"ui.ai_add_agent_title": "Add agent",
"ui.ai_add_api": "OpenAI-compatible API",
"ui.ai_add_custom": "Custom command",
"ui.ai_added": "added",
"ui.ai_add_installed": "Installed agent",
"ui.ai_add_installed_note": "An entry of your own for an installed agent, for example with other arguments. Installed agents are listed by themselves.",
"ui.ai_agent_mode_tip": "The agent's own mode: its own tools (shell, file edits) follow it. SolidCraft's tools follow the permissions in Preferences > AI.",
"ui.ai_agents": "Agents",
"ui.ai_always_allow": "Always allow",
"ui.ai_always_allowed": "Always allowed",
"ui.ai_always_allowed_none": "Nothing is always allowed yet. \"Always allow\" answers you give in the chat are listed here.",
"ui.ai_always_allow_tip": "Allow {tool} for this agent from now on, without asking",
"ui.ai_auto_allowed": "Auto-allowed: {title} ({why})",
"ui.ai_auto_mode": "your permission setting",
"ui.ai_auto_ours": "SolidCraft checks its own tools itself",
"ui.ai_auto_remembered": "you chose Always allow",
"ui.ai_bad_url": "Enter a URL that starts with http:// or https://",
"ui.ai_base_url": "Base URL",
"ui.ai_button": "AI",
"ui.ai_copy_suffix": "copy",
"ui.ai_custom_url": "Custom URL",
"ui.ai_default": "Default",
"ui.ai_http_busy": "the endpoint is busy or over its limit; try again later",
"ui.ai_http_key": "the endpoint refused the key: check it in Preferences > AI",
"ui.ai_http_not_found": "no such endpoint or model: check the base URL and the model name",
"ui.ai_key": "API key",
"ui.ai_keychain_error": "The system keychain refused: {error}",
"ui.ai_key_hint": "optional; kept in the system keychain",
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
"ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.",
"ui.ai_mode_all": "Allow everything",
"ui.ai_mode_all_note": "Nothing asks first. Crash reports stay off limits and the folder limits still apply.",
"ui.ai_mode_all_warning": "The agent can save, overwrite, export and replace your design without asking. Use this only with an agent you trust, on designs you have saved.",
"ui.ai_mode_ask": "Ask every time",
"ui.ai_mode_ask_note": "Every change to the design, and every file action, waits for your Allow.",
"ui.ai_mode_edits": "Auto-allow model edits",
"ui.ai_mode_edits_note": "Modelling commands run without asking; saving, opening, exporting and other file or destructive actions still ask.",
"ui.ai_mode_global": "As set above",
"ui.ai_model": "Model",
"ui.ai_modes_scope": "These settings cover SolidCraft's tools. An agent's own tools (its shell, its file edits) follow the agent's own mode, which you pick in the chat panel's header when the agent has modes.",
"ui.ai_name": "Name",
"ui.ai_need_command": "Enter the command that starts the agent",
"ui.ai_need_model": "Enter the model name",
"ui.ai_no_answer": "The agent did not answer in time",
"ui.ai_no_http": "This build can't reach API endpoints",
"ui.ai_no_keychain": "The system keychain isn't available, so a key can't be stored. A local server without a key still works.",
"ui.ai_panel": "Panel",
"ui.ai_permissions": "Permissions",
"ui.ai_preset": "Preset",
"ui.ai_remove": "Remove",
"ui.ai_request_limit": "stopped after 24 requests for one message; send another message to go on",
"ui.ai_reset": "Reset",
"ui.ai_settings": "AI settings…",
"ui.ai_show_features": "Show AI features",
"ui.ai_show_features_note": "The agent chat: a panel where a coding agent you already use, or a model endpoint you choose, works on the open design. Off: no AI button, shortcut, menu entry or panel, and nothing is looked for or started.",
"ui.ai_switch_tip": "Change the agent for this conversation",
"ui.ai_test": "Test",
"ui.ai_test_acp_ok": "Answers: {agent}",
"ui.ai_test_acp_tip": "Starts the agent briefly and asks it for a session; ends it again",
"ui.ai_test_api_tip": "Asks the endpoint for its models",
"ui.ai_testing": "Testing…",
"ui.ai_test_no_model": "Reached: {count} models, but not {model}",
"ui.ai_test_ok": "Reached: {count} models",
"ui.ai_toolbar_button": "Show button in toolbar",
"ui.ai_toolbar_button_note": "The AI button at the right end of the toolbar. Ctrl+Shift+A and the View menu still open the panel.",
"ui.ai_tool_done": "done",
"ui.ai_tool_failed": "failed",
"ui.ai_tool_running": "running",
"ui.ai_tool_unfold": "Click to show what it returned",
"ui.ai_tool_waiting": "waiting",
"ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step",
"AI": "AI",
"ui.all": "All",
"ui.analysis": "Analysis",
"ui.angle": "Angle",
@@ -317,6 +500,16 @@
"ui.animation": "Animation",
"ui.animation_speed": "Animation speed",
"ui.anti_aliasing": "Anti-aliasing",
"ui.supersampling": "Supersampling (SSAA)",
"ui.supersampling_off": "Off (1×)",
"ui.supersampling_adaptive": "Only when the view is still",
"settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.",
"ui.display_quality": "Display quality",
"ui.display_quality_coarse": "Coarse",
"ui.display_quality_normal": "Normal",
"ui.display_quality_fine": "Fine",
"ui.display_quality_ultra": "Ultra",
"settings.display_quality_note": "Finer levels draw curved faces and edges smoother and take longer to mesh. Only the viewport changes, never the design or exports.",
"ui.apply": "Apply",
"ui.arc": "Arc",
"ui.assemble": "Assemble",
@@ -346,10 +539,47 @@
"ui.click_selects_whole_bodies": "Click selects whole bodies",
"ui.click_then_press_key": "Click, then press the new key",
"ui.close": "Close",
"ui.material_review.title": "Review Physical Material Data",
"ui.material_review.scope": "Candidate only: not assigned or saved. Numerical validation is not engineering certification.",
"ui.material_review.input": "Paste a schema-1 material card (JSON)",
"ui.material_review.review": "Review card",
"ui.material_review.limit": "The card exceeds the 256 KiB input limit.",
"ui.material_review.unknown": "Unknown",
"ui.material_review.properties": "Properties",
"ui.material_review.conditions": "Stated conditions",
"ui.material_review.source": "Source",
"ui.material_review.axes": "Material-local axes; no body orientation is assigned.",
"ui.material_review.derived": "Derived isotropic moduli",
"ui.material_review.claims": "Sources and licences are unverified claims. Missing values remain unknown.",
"ui.material_review.density": "Density",
"ui.material_review.elasticity": "Elasticity",
"ui.material_review.tensile_strength": "Tensile strength",
"ui.material_review.compressive_strength": "Compressive strength",
"ui.material_review.yield_strength": "Yield strength",
"ui.material_review.shear_strength": "Shear strength",
"ui.material_review.thermal_conductivity": "Thermal conductivity",
"ui.material_review.thermal_expansion": "Thermal expansion",
"ui.material_review.specific_heat": "Specific heat",
"ui.material_review.moisture_unit": "kg water / kg dry material",
"ui.coil": "Coil",
"ui.coincident": "Coincident",
"ui.collapse": "Collapse",
"ui.collinear": "Collinear",
"ui.colour_vision": "Colour vision",
"ui.colour_vision_deutan": "Deutan",
"ui.colour_vision_deutan_tip": "For a weak or missing green sense",
"ui.colour_vision_failed": "Failed",
"ui.colour_vision_fixed": "Fixed",
"ui.colour_vision_mode": "Mode",
"ui.colour_vision_note": "Changes the colours that carry a meaning: sketch states, axes, bends, analyses and icons. If you use Windows colour filters, keep this Off.",
"ui.colour_vision_off": "Off",
"ui.colour_vision_off_tip": "The theme's own colours",
"ui.colour_vision_projected": "Projected",
"ui.colour_vision_protan": "Protan",
"ui.colour_vision_protan_tip": "For a weak or missing red sense",
"ui.colour_vision_sample": "Sample",
"ui.colour_vision_tritan": "Tritan",
"ui.colour_vision_tritan_tip": "For a weak or missing blue sense",
"ui.combine": "Combine",
"ui.coming_later": "coming later",
"ui.concentric": "Concentric",
@@ -561,6 +791,10 @@
"ui.quit": "Quit",
"ui.radial_context_menu": "Radial context menu",
"ui.radius": "Radius",
"ui.pull_direction": "Pull direction",
"ui.tool_direction": "Tool direction",
"ui.reachable": "Reachable",
"ui.blocked": "Blocked",
"ui.radius_type": "Radius Type",
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Rectangular Pattern",
@@ -575,9 +809,33 @@
"ui.revolve": "Revolve",
"ui.right": "Right",
"ui.save": "Save",
"ui.heading_save_mesh": "SAVE AS MESH",
"ui.format": "Format",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF with face tags",
"ui.send_to_slicer": "Send to slicer",
"ui.slicer": "Slicer",
"ui.custom_program": "Custom…",
"ui.no_slicer_found": "No slicer found",
"ui.send": "Send",
"ui.custom_program_tip": "Choose a slicer program to add to the list",
"ui.slicer_delete_tip": "{program}\nPress Delete to remove it from the list",
"ui.save_mesh_pick_bodies": "Select the bodies to save",
"ui.save_mesh_no_slicer": "No slicer found: choose Custom… and pick your slicer program, or turn off Send to slicer",
"ui.not_a_program": "{path} is not a program that can be started",
"ui.sent_to_slicer": "Sent to {slicer}",
"ui.saved_to": "Saved {path}",
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
"ui.save_as": "Save As",
"ui.save_as_ellipsis": "Save As...",
"ui.scale": "Scale",
"ui.orientation": "Orientation",
"ui.twist": "Twist",
"ui.end_scale": "End Scale",
"ui.sweep_turn_with_path": "Turn with Path",
"ui.sweep_keep_as_drawn": "Keep as Drawn",
"ui.sweep_follow_direction": "Follow Direction",
"ui.section_analysis": "Section Analysis",
"ui.select": "Select",
"ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
@@ -729,6 +987,8 @@
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Files from many CAD programs have Y up, so they open lying on their back here (Z up).",
"Filter": "Filter",
"Find in Browser": "Find in Browser",
"Copy Error Message": "Copy Error Message",
"Copy Warning Message": "Copy Warning Message",
"Flat size": "Flat size",
"Follow camera (Look At sketches)": "Follow camera (Look At sketches)",
"Font": "Font",
@@ -917,6 +1177,17 @@
"ui.pinToShortcuts": "Pin to Shortcuts",
"ui.pinToToolbar": "Pin to Toolbar",
"ui.properties": "Properties",
"ui.design_properties.project": "Project",
"ui.design_properties.part_number": "Part number",
"ui.design_properties.revision": "Revision",
"ui.design_properties.author": "Author",
"ui.design_properties.organization": "Organization",
"ui.design_properties.description": "Description",
"ui.design_properties.license": "License",
"ui.design_properties.not_text": "Saved as something other than text; typing here replaces it.",
"ui.design_properties.exports": "STEP, IGES and 3MF exports carry these where the format has a place for them.",
"ui.design_properties.others": "Other tools' data",
"ui.design_properties.others_note": "Kept in the design as it is and never exported.",
"ui.removeSection": "Remove Section",
"ui.removeFromToolbar": "Remove from Toolbar",
"ui.repeat": "Repeat",
@@ -940,6 +1211,9 @@
"ui.updateToCurrentView": "Update to Current View",
"ui.zoom": "Zoom",
"ui.command_done": "{command} done",
"drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further",
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
"drive.unconnected": "Moves nothing: {part} is not connected to anything grounded",
"timeline.group": "New Feature Folder",
"timeline.ungroup": "Remove Feature Folders",
"timeline.rename_group": "Rename Feature Folder",
@@ -980,6 +1254,18 @@
"cmd.material_properties_directional": "Directional Elastic Modulus",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} is available",
"install.menu": "Install app…",
"install.button_tip": "Install SolidCraft as an app: it opens in its own window and works offline",
"install.title": "Install SolidCraft",
"install.steps_intro": "Safari installs web apps from its own menus:",
"install.home_screen_1": "Tap Share (in the … menu if it isn't on the toolbar).",
"install.home_screen_2": "Choose Add to Home Screen.",
"install.home_screen_3": "Tap Add.",
"install.home_screen_after": "SolidCraft then opens from its icon on the Home Screen, without Safari's toolbars, and works offline.",
"install.dock_1": "Choose File › Add to Dock.",
"install.dock_2": "Click Add.",
"install.dock_after": "SolidCraft then opens in its own window from the Dock, and works offline.",
"install.done": "Done",
"update.title": "Update available",
"update.available": "SolidCraft {version} is available",
"update.you_have": "You have {version}.",
@@ -1011,5 +1297,85 @@
"update.restart_later": "Later",
"update.installer_started": "The installer starts when SolidCraft closes.",
"update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.",
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead."
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead.",
"cmd.render_settings_get": "Render Settings",
"cmd.render_settings_set": "Save Render Settings",
"cmd.render_scene": "Inspect Render Scene",
"cmd.render_settings_apply": "Apply Render Camera",
"toolbar.render": "RENDER",
"toolbar.render_scene": "SCENE",
"render.preview": "Integrated preview",
"render.preview_help": "Raster shading and blended transparency. Refraction, HDRI and final rendering are not available yet.",
"render.setup_name": "Setup name",
"render.background": "Scene background",
"render.perspective": "Perspective camera",
"render.fit": "Fit Product",
"render.restore_camera": "Restore Saved Camera",
"render.save_setup": "Save Setup",
"render.unsaved": "Unsaved preview changes",
"render.reload_settings": "Reload Saved Settings",
"render.unavailable_preview": "This setup requests an unavailable preview profile.",
"render.use_preview": "Use Integrated Preview",
"render.navigation_help": "The camera moves as in Design (Preferences › Navigation). Save Setup records the current camera.",
"render.return_design": "Return to Design",
"render.image_title": "Render Image",
"render.image_help": "CPU preview image · PNG · approximate transparency",
"render.image_width": "Width",
"render.image_height": "Height",
"render.image_transparent": "Transparent background",
"render.image_check": "Check Render Request",
"render.image_supported": "Render request is supported; the queue and memory pool are checked again at start.",
"render.image_queued": "Waiting to render",
"render.image_preparing": "Preparing image",
"render.image_running": "Rendering image",
"render.image_encoding": "Encoding PNG",
"render.image_cancelling": "Cancelling",
"render.image_cancelled": "Render cancelled",
"render.image_completed": "Image ready to export",
"render.image_failed": "Render failed",
"render.image_unknown": "Render status unavailable",
"render.image_reduce": "Try a smaller image or simpler scene.",
"render.image_too_large": "Over the image limit: 4096 pixels per side and 4,194,304 pixels in all.",
"render.image_use_largest": "Use the largest size at this aspect:",
"render.image_cancel": "Cancel Render",
"render.image_stale": "This image uses the scene captured before subsequent changes.",
"render.image_start": "Start Render",
"render.image_overwrite": "Replace existing file",
"render.image_export": "Export PNG",
"cmd.render_preflight": "Check Render Request",
"cmd.render_start": "Render Image",
"cmd.render_status": "Render Progress",
"cmd.render_cancel": "Cancel Render",
"cmd.render_export": "Export Render Image",
"toolbar.image": "IMAGE",
"appearance.surface_response": "Surface response",
"appearance.pbr_opaque": "Opaque PBR",
"appearance.metallic": "Metallic",
"appearance.roughness": "Roughness",
"appearance.dielectric_ior": "Dielectric IOR",
"appearance.pbr_help": "View this surface in Render or a product PNG. No refraction or transmission.",
"render.opaque_preview_help": "Opaque PBR and simple colors; blended transparency. Refraction, HDRI and final rendering are not available yet.",
"render.opaque_image_summary": "Preview PNG · opaque PBR · blended transparency",
"UNTRIM": "UNTRIM",
"Face": "Face",
"Holes": "Holes",
"Holes (edges on them)": "Holes (edges on them)",
"In place (surfaces)": "In place (surfaces)",
"None": "None",
"Picked": "Picked",
"Restore outer boundary": "Restore outer boundary",
"RULED SURFACE": "RULED SURFACE",
"Mitre": "Mitre",
"Reference face": "Reference face",
"Round": "Round",
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.",
"Circumference": "Circumference",
"Arc length": "Arc length",
"Slot width": "Slot width",
"Centre to centre": "Centre to centre",
"Overall length": "Overall length",
"Min": "Min",
"Max": "Max"
}
File diff suppressed because it is too large Load Diff
+367 -1
View File
@@ -4,6 +4,12 @@
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 下のボタンでGitHubの下書きを開きます。タイトルとレポートは入力済みです。",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. スクリーンショットを添付します。コピーして貼り付けるか、PNGを保存してアップロードします。",
"3. Click Submit new issue on GitHub to send your report.": "3. GitHubでSubmit new issueをクリックしてレポートを送信します。",
"nav.touchpad.label": "タッチパッド",
"nav.touchpad.help": "Shift を押しながらポインターを動かすとパン、Alt/Option を押しながら動かすと回転します。マウスボタンは不要です。縦スクロールでズームします。",
"nav.touchpad.orbit": "回転(Alt/Option を押しながら移動、マウスボタン不要)",
"nav.touchpad.pan": "パン(Shift を押しながら移動、マウスボタン不要)",
"nav.touchpad.zoom": "ズーム(縦スクロールまたはピンチ)",
"nav.touchpad.scroll": "タッチパッドでは縦スクロールでズームします。2 本指パンの設定は他のプリセット用に保持されます。",
"browser.body_name": "ボディ{number}",
"browser.component_name": "コンポーネント{number}",
"browser.parameters": "パラメータ ({count})",
@@ -30,6 +36,7 @@
"cmd.component_capture_position": "位置をキャプチャ",
"cmd.component_create": "新規コンポーネント",
"cmd.component_delete": "コンポーネントを削除",
"cmd.component_unlock": "内容のロックを解除",
"cmd.component_from_bodies": "ボディからコンポーネントを作成",
"cmd.component_insert_design": "デザインを挿入",
"cmd.component_links": "リンクを一覧表示",
@@ -82,6 +89,9 @@
"cmd.doc_recovery_list": "復元可能なデザイン",
"cmd.document_inspect": "デザインを検査",
"cmd.document_units": "デザインの単位を変更",
"cmd.document_metadata": "デザインのプロパティ",
"cmd.document_metadata_set": "デザインのプロパティを変更",
"cmd.document_metadata_remove": "デザインのプロパティを削除",
"cmd.edit_checkpoint": "チェックポイント",
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
"cmd.engine_commands": "コマンドを一覧表示",
@@ -95,8 +105,10 @@
"cmd.file_insert_mesh": "メッシュを挿入",
"cmd.file_insert_step": "STEP を挿入",
"cmd.file_save_mesh": "メッシュとして保存",
"cmd.file_slicers": "インストール済みのスライサー",
"cmd.inspect_accessibility": "アクセシビリティ解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_compare": "ボディを比較",
"cmd.inspect_curvature_comb": "曲率コーム解析",
"cmd.inspect_curvature_map": "曲率マップ解析",
"cmd.inspect_draft": "勾配解析",
@@ -141,6 +153,7 @@
"cmd.parameters_delete": "パラメータを削除",
"cmd.parameters_export": "パラメータをエクスポート",
"cmd.parameters_favorite": "お気に入りパラメータ",
"cmd.parameters_move": "パラメータを移動",
"cmd.parameters_graph": "パラメータ グラフ",
"cmd.parameters_import": "パラメータをインポート",
"cmd.parameters_list": "パラメータを一覧表示",
@@ -190,6 +203,8 @@
"cmd.sketch_constraint_fix": "固定/固定解除",
"cmd.sketch_constraint_midpoint": "中点",
"cmd.sketch_construction": "標準/コンストラクション",
"cmd.sketch_copy": "スケッチ要素をコピー",
"cmd.sketch_cut": "スケッチ要素を切り取り",
"cmd.sketch_delete": "スケッチ要素を削除",
"cmd.sketch_dimension_text": "寸法テキストを移動",
"cmd.sketch_drag": "スケッチ ジオメトリをドラッグ",
@@ -214,6 +229,7 @@
"cmd.sketch_move_point": "スケッチ点をドラッグ",
"cmd.sketch_offset_break": "オフセット関連付けを解除",
"cmd.sketch_options": "スケッチ パレット オプション",
"cmd.sketch_paste": "スケッチ要素を貼り付け",
"cmd.sketch_point3d": "3D 点",
"cmd.sketch_polygon_circumscribed": "外接ポリゴン",
"cmd.sketch_polygon_edge": "エッジ ポリゴン",
@@ -283,6 +299,7 @@
"cmd.view_list": "名前付きビューを一覧表示",
"cmd.view_rename": "名前付きビューの名前を変更",
"cmd.view_save": "新規名前付きビュー",
"dialog.computing": "計算中… {seconds} 秒",
"dialog.custom_axis": "↖ カスタム軸",
"dialog.edit_title": "{title}を編集",
"dialog.select": "↖ 選択",
@@ -292,6 +309,31 @@
"dialog.selection_guidance": "入力欄を選んで変更します。選択済みのオブジェクトをクリックすると解除できます。Ctrl/Cmd を押しながらクリックしても切り替えられます。プレビューを確認してから OK を押してください。",
"document.save_changes": "{name} への変更を保存しますか?",
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
"home.documentation": "ドキュメント",
"home.in_this_browser": "このブラウザ内",
"home.missing": "見つかりません",
"home.open": "開く…",
"home.opened_days_ago": "{count} 日前に開きました",
"home.opened_hours_ago": "{count} 時間前に開きました",
"home.opened_just_now": "たった今開きました",
"home.opened_minutes_ago": "{count} 分前に開きました",
"home.opening": "開いています…",
"home.recent": "最近使用した項目",
"home.report_issue": "問題を報告",
"home.samples": "サンプル",
"home.whats_new": "新機能",
"sample.bracket.about": "2 mm の板金ルールによるベース フランジ、エッジ フランジ、ヘミング",
"sample.bracket.name": "板金ブラケット",
"sample.enclosure.about": "リップ、ねじボス、レスト、スナップ フィットを備えたシェル化した直方体",
"sample.enclosure.name": "プラスチック筐体",
"sample.flange.about": "パラメータで制御する円形パターンを持つハブとフランジ",
"sample.flange.name": "ボルト締めフランジ",
"sample.hinge.about": "2 つのコンポーネントと回転ジョイント、60° まで駆動",
"sample.hinge.name": "ヒンジ アセンブリ",
"sample.plate.about": "フィレット、穴、穴あきボスを備えたパラメトリック プレート",
"sample.plate.name": "サンプル プレート",
"sample.vents.about": "矩形パターンに並んだスロット: 行数、列数、ピッチはパラメータ",
"sample.vents.name": "通気プレート",
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
"settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。",
"settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません",
@@ -309,6 +351,147 @@
"ui.active_component": "アクティブなコンポーネント",
"ui.agent_cursor": "エージェントカーソル",
"ui.agent_cursor_speed": "エージェントカーソル速度",
"ui.agent_chat": "エージェントチャット",
"ui.agent_chat_command": "エージェントのコマンド",
"ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。",
"ui.agent_chat_side": "エージェントチャットの位置",
"ui.agent_chat_left": "左",
"ui.agent_chat_right": "右",
"ui.agent_chat_turn_limit": "1メッセージあたりの変更数",
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
"ui.agent_chat_connect": "接続",
"ui.agent_chat_stop": "停止",
"ui.agent_chat_stop_tip": "エージェントと、それが起動したものをすべて停止します",
"ui.agent_chat_close": "閉じる",
"ui.agent_chat_minimize": "細いバーに最小化",
"ui.agent_chat_dock_left": "左にドッキング",
"ui.agent_chat_dock_right": "右にドッキング",
"ui.agent_chat_send": "送信",
"ui.agent_chat_cancel": "キャンセル",
"ui.agent_chat_refresh": "再検索",
"ui.agent_chat_pick": "エージェント",
"ui.agent_chat_custom_hint": "標準入出力で Agent Client Protocol を話す任意のプログラム。シェルは使いません。",
"ui.agent_chat_not_installed": "未インストール",
"ui.agent_chat_none_found": "コーディングエージェントが見つかりません。次のいずれかをインストールしてから「再検索」を押してください:",
"ui.agent_chat_detecting": "エージェントを検索中…",
"ui.agent_chat_signed_in": "サインイン済み",
"ui.agent_chat_key_in_env": "キー設定済み",
"ui.agent_chat_not_signed_in": "未サインイン",
"ui.agent_chat_state_off": "未接続",
"ui.agent_chat_state_starting": "起動中…",
"ui.agent_chat_state_ready": "準備完了",
"ui.agent_chat_state_busy": "作業中…",
"ui.agent_chat_state_auth": "サインインが必要",
"ui.agent_chat_state_exited": "エージェント停止",
"ui.agent_chat_changes_of": "変更 {n}/{limit}",
"ui.agent_chat_auth_title": "エージェントにサインイン",
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
"ui.agent_chat_auth_none": "このエージェントはここでのサインイン方法を提示していません。エージェントのドキュメントに従ってサインインしてください。",
"ui.agent_chat_auth_terminal": "ターミナルでこれを実行してから、もう一度「接続」を押してください:",
"ui.agent_chat_auth_agent": "{name} でサインイン",
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
"ui.agent_chat_auth_retry": "サインインしました: 再接続",
"ui.agent_chat_copy": "コピー",
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
"ui.agent_chat_answered": "回答: {option}",
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
"ui.agent_chat_allow": "許可",
"ui.agent_chat_deny": "拒否",
"ui.agent_chat_allowed": "許可しました",
"ui.agent_chat_denied": "拒否しました",
"ui.agent_chat_changes": "{n} 件の変更(元に戻すは1回)",
"ui.agent_chat_undo_changes": "これらの変更を元に戻す",
"ui.agent_chat_undone": "(元に戻しました)",
"ui.agent_chat_undo_label": "エージェントチャット: {message}",
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
"ui.agent_chat_refused": "エージェントには許可されていません: {reason}",
"ui.agent_chat_wait_turn": "エージェントはまだ前のメッセージを処理中です。待つか「キャンセル」を押してください。",
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
"ui.agent_chat_attach_view": "ビューを添付",
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
"ui.agent_chat_view_attached": "ビューを添付済み",
"ui.agent_chat_no_cli": "アプリの隣に solidcraft-cli が見つからないため、エージェントはデザインにアクセスできません。",
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
"ui.agent_chat_exited": "エージェントが停止しました。",
"ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}",
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
"ui.ai_add": "追加",
"ui.ai_add_agent": "エージェントを追加…",
"ui.ai_add_agent_title": "エージェントを追加",
"ui.ai_add_api": "OpenAI 互換 API",
"ui.ai_add_custom": "カスタムコマンド",
"ui.ai_added": "追加済み",
"ui.ai_add_installed": "インストール済みエージェント",
"ui.ai_add_installed_note": "インストール済みエージェントの独自エントリ(例: 別の引数で起動)。インストール済みのエージェントは自動で一覧に表示されます。",
"ui.ai_agent_mode_tip": "エージェント自身のモード: エージェント自身のツール(シェル、ファイル編集)はこれに従います。SolidCraft のツールは「環境設定 > AI」の権限に従います。",
"ui.ai_agents": "エージェント",
"ui.ai_always_allow": "常に許可",
"ui.ai_always_allowed": "常に許可されたもの",
"ui.ai_always_allowed_none": "まだ常に許可されたものはありません。チャットで「常に許可」と答えたものがここに表示されます。",
"ui.ai_always_allow_tip": "今後このエージェントに {tool} を確認なしで許可します",
"ui.ai_auto_allowed": "自動許可: {title}({why})",
"ui.ai_auto_mode": "権限の設定",
"ui.ai_auto_ours": "SolidCraft のツールは SolidCraft 自身が確認します",
"ui.ai_auto_remembered": "「常に許可」を選択済み",
"ui.ai_bad_url": "http:// または https:// で始まる URL を入力してください",
"ui.ai_base_url": "ベース URL",
"ui.ai_button": "AI",
"ui.ai_copy_suffix": "コピー",
"ui.ai_custom_url": "カスタム URL",
"ui.ai_default": "既定",
"ui.ai_http_busy": "エンドポイントが混雑しているか上限に達しています。後で再試行してください",
"ui.ai_http_key": "エンドポイントがキーを拒否しました。「環境設定 > AI」で確認してください",
"ui.ai_http_not_found": "エンドポイントまたはモデルが見つかりません。ベース URL とモデル名を確認してください",
"ui.ai_key": "API キー",
"ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}",
"ui.ai_key_hint": "任意。システムのキーチェーンに保存",
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
"ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
"ui.ai_mode_all": "すべて許可",
"ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。",
"ui.ai_mode_all_warning": "エージェントは確認なしでデザインの保存・上書き・エクスポート・置き換えができます。信頼できるエージェントで、保存済みのデザインに対してのみ使ってください。",
"ui.ai_mode_ask": "毎回確認",
"ui.ai_mode_ask_note": "デザインへのすべての変更とファイル操作が、あなたの許可を待ちます。",
"ui.ai_mode_edits": "モデル編集を自動許可",
"ui.ai_mode_edits_note": "モデリングコマンドは確認なしで実行されます。保存・開く・エクスポートなどのファイル操作や破壊的な操作は引き続き確認します。",
"ui.ai_mode_global": "上の設定に従う",
"ui.ai_model": "モデル",
"ui.ai_modes_scope": "これらの設定は SolidCraft のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。",
"ui.ai_name": "名前",
"ui.ai_need_command": "エージェントを起動するコマンドを入力してください",
"ui.ai_need_model": "モデル名を入力してください",
"ui.ai_no_answer": "エージェントが時間内に応答しませんでした",
"ui.ai_no_http": "このビルドは API エンドポイントに接続できません",
"ui.ai_no_keychain": "システムのキーチェーンが使えないため、キーを保存できません。キー不要のローカルサーバーは利用できます。",
"ui.ai_panel": "パネル",
"ui.ai_permissions": "権限",
"ui.ai_preset": "プリセット",
"ui.ai_remove": "削除",
"ui.ai_request_limit": "1メッセージあたり24リクエストで停止しました。続けるには、もう一度メッセージを送ってください",
"ui.ai_reset": "リセット",
"ui.ai_settings": "AI 設定…",
"ui.ai_show_features": "AI 機能を表示",
"ui.ai_show_features_note": "エージェントチャット: 普段使っているコーディングエージェント、または選んだモデルのエンドポイントが、開いているデザインを操作するパネルです。オフ: AI ボタン、ショートカット、メニュー項目、パネルは表示されず、検索も起動も行いません。",
"ui.ai_switch_tip": "この会話のエージェントを切り替え",
"ui.ai_test": "テスト",
"ui.ai_test_acp_ok": "応答あり: {agent}",
"ui.ai_test_acp_tip": "エージェントを短時間起動してセッションを要求し、終了します",
"ui.ai_test_api_tip": "エンドポイントにモデル一覧を問い合わせます",
"ui.ai_testing": "テスト中…",
"ui.ai_test_no_model": "接続成功: {count} 個のモデル({model} はありません)",
"ui.ai_test_ok": "接続成功: {count} 個のモデル",
"ui.ai_toolbar_button": "ツールバーにボタンを表示",
"ui.ai_toolbar_button_note": "ツールバー右端の AI ボタン。Ctrl+Shift+A と表示メニューからも引き続きパネルを開けます。",
"ui.ai_tool_done": "完了",
"ui.ai_tool_failed": "失敗",
"ui.ai_tool_running": "実行中",
"ui.ai_tool_unfold": "クリックして結果を表示",
"ui.ai_tool_waiting": "待機中",
"ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します",
"AI": "AI",
"ui.all": "すべて",
"ui.analysis": "解析",
"ui.angle": "角度",
@@ -317,6 +500,16 @@
"ui.animation": "アニメーション",
"ui.animation_speed": "アニメーション速度",
"ui.anti_aliasing": "アンチエイリアス",
"ui.supersampling": "スーパーサンプリング (SSAA)",
"ui.supersampling_off": "オフ (1×)",
"ui.supersampling_adaptive": "ビューが静止しているときのみ",
"settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。",
"ui.display_quality": "表示品質",
"ui.display_quality_coarse": "粗い",
"ui.display_quality_normal": "標準",
"ui.display_quality_fine": "細かい",
"ui.display_quality_ultra": "最高",
"settings.display_quality_note": "細かいほど曲面とエッジが滑らかに表示されますが、メッシュの生成に時間がかかります。変わるのはビューポートだけで、設計やエクスポートは変わりません。",
"ui.apply": "適用",
"ui.arc": "円弧",
"ui.assemble": "アセンブリ",
@@ -346,10 +539,47 @@
"ui.click_selects_whole_bodies": "クリックでボディ全体を選択",
"ui.click_then_press_key": "クリックした後、割り当てるキーを押してください",
"ui.close": "閉じる",
"ui.material_review.title": "物理材料データを確認",
"ui.material_review.scope": "候補のみ:割り当てや保存は行いません。数値検証は工学的認証ではありません。",
"ui.material_review.input": "スキーマ1の材料カードを貼り付け(JSON)",
"ui.material_review.review": "カードを確認",
"ui.material_review.limit": "カードが256 KiBの入力上限を超えています。",
"ui.material_review.unknown": "不明",
"ui.material_review.properties": "特性",
"ui.material_review.conditions": "記載された条件",
"ui.material_review.source": "出典",
"ui.material_review.axes": "材料ローカル軸。ボディの方向は割り当てません。",
"ui.material_review.derived": "導出された等方性弾性率",
"ui.material_review.claims": "出典とライセンスの申告は未確認です。欠落値は不明のままです。",
"ui.material_review.density": "密度",
"ui.material_review.elasticity": "弾性",
"ui.material_review.tensile_strength": "引張強度",
"ui.material_review.compressive_strength": "圧縮強度",
"ui.material_review.yield_strength": "降伏強度",
"ui.material_review.shear_strength": "せん断強度",
"ui.material_review.thermal_conductivity": "熱伝導率",
"ui.material_review.thermal_expansion": "熱膨張",
"ui.material_review.specific_heat": "比熱",
"ui.material_review.moisture_unit": "kg 水 / kg 乾燥材料",
"ui.coil": "コイル",
"ui.coincident": "一致",
"ui.collapse": "折りたたむ",
"ui.collinear": "同一直線上",
"ui.colour_vision": "色覚",
"ui.colour_vision_deutan": "2型色覚(D型)",
"ui.colour_vision_deutan_tip": "緑を感じにくい、または感じない場合に",
"ui.colour_vision_failed": "エラー",
"ui.colour_vision_fixed": "固定",
"ui.colour_vision_mode": "モード",
"ui.colour_vision_note": "意味を持つ色を変更します(スケッチの状態、軸、曲げ、解析、アイコン)。Windows のカラー フィルターを使う場合は「オフ」のままにしてください。",
"ui.colour_vision_off": "オフ",
"ui.colour_vision_off_tip": "テーマ本来の色",
"ui.colour_vision_projected": "投影",
"ui.colour_vision_protan": "1型色覚(P型)",
"ui.colour_vision_protan_tip": "赤を感じにくい、または感じない場合に",
"ui.colour_vision_sample": "サンプル",
"ui.colour_vision_tritan": "3型色覚(T型)",
"ui.colour_vision_tritan_tip": "青を感じにくい、または感じない場合に",
"ui.combine": "結合",
"ui.coming_later": "準備中",
"ui.concentric": "同心",
@@ -561,6 +791,10 @@
"ui.quit": "終了",
"ui.radial_context_menu": "パイ メニュー",
"ui.radius": "半径",
"ui.pull_direction": "抜き方向",
"ui.tool_direction": "工具方向",
"ui.reachable": "到達可能",
"ui.blocked": "到達不可",
"ui.radius_type": "半径タイプ",
"ui.rectangle": "長方形",
"ui.rectangular_pattern": "矩形パターン",
@@ -575,9 +809,33 @@
"ui.revolve": "回転",
"ui.right": "右側面",
"ui.save": "保存",
"ui.heading_save_mesh": "メッシュとして保存",
"ui.format": "形式",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "面タグ付き 3MF",
"ui.send_to_slicer": "スライサーに送る",
"ui.slicer": "スライサー",
"ui.custom_program": "カスタム…",
"ui.no_slicer_found": "スライサーが見つかりません",
"ui.send": "送信",
"ui.custom_program_tip": "リストに追加するスライサーのプログラムを選択",
"ui.slicer_delete_tip": "{program}\nDelete キーでリストから削除",
"ui.save_mesh_pick_bodies": "保存するボディを選択してください",
"ui.save_mesh_no_slicer": "スライサーが見つかりません。「カスタム…」でスライサーのプログラムを選ぶか、「スライサーに送る」をオフにしてください",
"ui.not_a_program": "{path} は起動できるプログラムではありません",
"ui.sent_to_slicer": "{slicer} に送信しました",
"ui.saved_to": "保存しました: {path}",
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
"ui.save_as": "名前を付けて保存",
"ui.save_as_ellipsis": "名前を付けて保存…",
"ui.scale": "尺度",
"ui.orientation": "向き",
"ui.twist": "ねじれ",
"ui.end_scale": "終端スケール",
"ui.sweep_turn_with_path": "パスに沿って回転",
"ui.sweep_keep_as_drawn": "描いた向きを保持",
"ui.sweep_follow_direction": "方向に従う",
"ui.section_analysis": "断面解析",
"ui.select": "選択",
"ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
@@ -729,6 +987,8 @@
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "多くのCADではY軸を上向きとしているため、Z軸を上向きとするSolidCraftでは、読み込んだモデルが横倒しの向きで表示されることがあります。",
"Filter": "フィルター",
"Find in Browser": "ブラウザで検索",
"Copy Error Message": "エラーメッセージをコピー",
"Copy Warning Message": "警告メッセージをコピー",
"Flat size": "展開サイズ",
"Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)",
"Font": "フォント",
@@ -917,6 +1177,17 @@
"ui.pinToShortcuts": "ショートカットに固定",
"ui.pinToToolbar": "ツールバーに固定",
"ui.properties": "プロパティ",
"ui.design_properties.project": "プロジェクト",
"ui.design_properties.part_number": "部品番号",
"ui.design_properties.revision": "リビジョン",
"ui.design_properties.author": "作成者",
"ui.design_properties.organization": "組織",
"ui.design_properties.description": "説明",
"ui.design_properties.license": "ライセンス",
"ui.design_properties.not_text": "テキスト以外の値で保存されています。ここに入力すると置き換わります。",
"ui.design_properties.exports": "STEP、IGES、3MF のエクスポートでは、形式に対応する項目があればこれらが書き込まれます。",
"ui.design_properties.others": "他のツールのデータ",
"ui.design_properties.others_note": "デザイン内にそのまま保持され、エクスポートされることはありません。",
"ui.removeSection": "セクションを解除",
"ui.removeFromToolbar": "ツールバーから削除",
"ui.repeat": "繰り返し",
@@ -940,6 +1211,9 @@
"ui.updateToCurrentView": "現在のビューに更新",
"ui.zoom": "ズーム",
"ui.command_done": "{command} 完了",
"drive.stopped_by_loop": "{value} で停止: {joint} を通るループはこれ以上閉じられません",
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
"drive.unconnected": "何も動きません: {part} は空間固定されたものに接続されていません",
"timeline.group": "フィーチャーフォルダを新規作成",
"timeline.ungroup": "フィーチャーフォルダを削除",
"timeline.rename_group": "フィーチャーフォルダの名前を変更",
@@ -980,6 +1254,18 @@
"cmd.material_properties_directional": "方向別弾性率",
"update.button": "アップデート",
"update.button_tip": "SolidCraft {version} が利用できます",
"install.menu": "アプリをインストール…",
"install.button_tip": "SolidCraft をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます",
"install.title": "SolidCraft をインストール",
"install.steps_intro": "Safari では、Web アプリを Safari 自身のメニューからインストールします:",
"install.home_screen_1": "「共有」をタップします(ツールバーにない場合は … メニューの中にあります)。",
"install.home_screen_2": "「ホーム画面に追加」を選びます。",
"install.home_screen_3": "「追加」をタップします。",
"install.home_screen_after": "SolidCraft はホーム画面のアイコンから Safari のツールバーなしで開き、オフラインでも使えます。",
"install.dock_1": "「ファイル」›「Dockに追加」を選びます。",
"install.dock_2": "「追加」をクリックします。",
"install.dock_after": "SolidCraft は Dock から専用のウィンドウで開き、オフラインでも使えます。",
"install.done": "完了",
"update.title": "アップデートがあります",
"update.available": "SolidCraft {version} が利用できます",
"update.you_have": "現在のバージョンは {version} です。",
@@ -1011,5 +1297,85 @@
"update.restart_later": "後で",
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。"
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。",
"cmd.render_settings_get": "レンダリング設定",
"cmd.render_settings_set": "レンダリング設定を保存",
"cmd.render_scene": "レンダリングシーンを確認",
"cmd.render_settings_apply": "レンダリングカメラを適用",
"toolbar.render": "レンダリング",
"toolbar.render_scene": "シーン",
"render.preview": "統合プレビュー",
"render.preview_help": "ラスタシェーディングとブレンド透明度を使用します。屈折、HDRI、最終レンダリングはまだ利用できません。",
"render.setup_name": "セットアップ名",
"render.background": "シーンの背景",
"render.perspective": "透視投影カメラ",
"render.fit": "製品を画面に合わせる",
"render.restore_camera": "保存したカメラを復元",
"render.save_setup": "セットアップを保存",
"render.unsaved": "未保存のプレビュー変更",
"render.reload_settings": "保存した設定を再読み込み",
"render.unavailable_preview": "このセットアップは利用できないプレビュープロファイルを要求しています。",
"render.use_preview": "統合プレビューを使用",
"render.navigation_help": "カメラはデザインと同じように動きます(環境設定 › ナビゲーション)。「セットアップを保存」で現在のカメラを記録します。",
"render.return_design": "設計に戻る",
"render.image_title": "画像をレンダリング",
"render.image_help": "CPUプレビュー画像 · PNG · 近似透過",
"render.image_width": "幅",
"render.image_height": "高さ",
"render.image_transparent": "透明な背景",
"render.image_check": "レンダリング要求を確認",
"render.image_supported": "レンダリング要求に対応しています。開始時にキューとメモリを再確認します。",
"render.image_queued": "レンダリング待機中",
"render.image_preparing": "画像を準備中",
"render.image_running": "画像をレンダリング中",
"render.image_encoding": "PNGをエンコード中",
"render.image_cancelling": "キャンセル中",
"render.image_cancelled": "レンダリングをキャンセルしました",
"render.image_completed": "画像をエクスポートできます",
"render.image_failed": "レンダリングに失敗しました",
"render.image_unknown": "レンダリング状態を取得できません",
"render.image_reduce": "画像を小さくするかシーンを簡略化してください。",
"render.image_too_large": "画像の上限を超えています:一辺 4096 ピクセル、合計 4,194,304 ピクセル。",
"render.image_use_largest": "この縦横比での最大サイズを使用:",
"render.image_cancel": "レンダリングをキャンセル",
"render.image_stale": "この画像は後続の変更前に取得したシーンを使用しています。",
"render.image_start": "レンダリングを開始",
"render.image_overwrite": "既存のファイルを置換",
"render.image_export": "PNGをエクスポート",
"cmd.render_preflight": "レンダリング要求を確認",
"cmd.render_start": "画像をレンダリング",
"cmd.render_status": "レンダリングの進行状況",
"cmd.render_cancel": "レンダリングをキャンセル",
"cmd.render_export": "PNGをエクスポート",
"toolbar.image": "画像",
"appearance.surface_response": "表面の応答",
"appearance.pbr_opaque": "不透明PBR",
"appearance.metallic": "金属度",
"appearance.roughness": "粗さ",
"appearance.dielectric_ior": "誘電体の屈折率",
"appearance.pbr_help": "この表面はレンダリング作業空間または製品PNGで確認できます。屈折や透過には対応していません。",
"render.opaque_preview_help": "不透明PBRと単色表示、アルファ合成による透明表示に対応します。屈折、HDRI、最終レンダリングにはまだ対応していません。",
"render.opaque_image_summary": "プレビューPNG · 不透明PBR · アルファ合成の透明表示",
"UNTRIM": "トリム解除",
"Face": "面",
"Holes": "穴",
"Holes (edges on them)": "穴(そのエッジ)",
"In place (surfaces)": "その場で(サーフェス)",
"None": "なし",
"Picked": "選択したもの",
"Restore outer boundary": "外側の境界を復元",
"RULED SURFACE": "ルールドサーフェス",
"Mitre": "留め継ぎ",
"Reference face": "参照面",
"Round": "丸め",
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。",
"Circumference": "円周",
"Arc length": "円弧の長さ",
"Slot width": "長穴の幅",
"Centre to centre": "中心間",
"Overall length": "全長",
"Min": "最小",
"Max": "最大"
}
+371 -1
View File
@@ -4,12 +4,19 @@
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 아래에서 GitHub 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 스크린샷을 첨부하세요. 복사하여 붙여넣거나 PNG로 저장한 뒤 업로드하세요.",
"3. Click Submit new issue on GitHub to send your report.": "3. GitHub에서 Submit new issue를 클릭하여 보고서를 보내세요.",
"nav.touchpad.label": "터치패드",
"nav.touchpad.help": "Shift를 누른 채 포인터를 움직이면 화면이 이동하고, Alt/Option을 누른 채 움직이면 회전합니다. 마우스 버튼은 필요 없습니다. 세로 스크롤로 확대/축소합니다.",
"nav.touchpad.orbit": "회전 (Alt/Option을 누르고 이동, 마우스 버튼 불필요)",
"nav.touchpad.pan": "화면 이동 (Shift를 누르고 이동, 마우스 버튼 불필요)",
"nav.touchpad.zoom": "확대/축소 (세로 스크롤 또는 핀치)",
"nav.touchpad.scroll": "터치패드는 세로 스크롤로 확대/축소합니다. 두 손가락 이동 설정은 다른 프리셋을 위해 유지됩니다.",
"browser.body_name": "바디{number}",
"browser.component_name": "컴포넌트{number}",
"browser.parameters": "매개변수 ({count})",
"browser.precision": "정밀도: {precision}",
"browser.sketch_name": "스케치{number}",
"browser.units": "단위: {units}",
"dialog.computing": "계산 중… {seconds}초",
"dialog.custom_axis": "↖ 사용자 정의 축",
"dialog.edit_title": "{title} 편집",
"dialog.select": "↖ 선택",
@@ -19,6 +26,31 @@
"dialog.selection_guidance": "변경할 입력을 선택하세요. 선택한 객체를 클릭하면 선택이 해제되며, Ctrl/Cmd를 누른 채 클릭해도 전환됩니다. 미리 보기를 확인한 다음 확인을 누르세요.",
"document.save_changes": "{name}의 변경 사항을 저장하시겠습니까?",
"document.unsaved_changes": "{name}에 저장되지 않은 변경 사항이 있습니다. 저장하지 않으면 변경 사항이 사라집니다.",
"home.documentation": "문서",
"home.in_this_browser": "이 브라우저에 저장됨",
"home.missing": "찾을 수 없음",
"home.open": "열기…",
"home.opened_days_ago": "{count}일 전에 열림",
"home.opened_hours_ago": "{count}시간 전에 열림",
"home.opened_just_now": "방금 열림",
"home.opened_minutes_ago": "{count}분 전에 열림",
"home.opening": "여는 중…",
"home.recent": "최근 항목",
"home.report_issue": "문제 신고",
"home.samples": "샘플",
"home.whats_new": "새로운 기능",
"sample.bracket.about": "2 mm 판금 규칙을 적용한 베이스 플랜지, 에지 플랜지, 헴",
"sample.bracket.name": "판금 브래킷",
"sample.enclosure.about": "리프, 나사 보스, 레스트, 스냅핏이 있는 쉘 처리된 박스",
"sample.enclosure.name": "플라스틱 인클로저",
"sample.flange.about": "매개변수로 제어되는 원형 패턴이 있는 허브와 플랜지",
"sample.flange.name": "볼트 플랜지",
"sample.hinge.about": "두 구성 요소와 회전 관절, 60°까지 구동",
"sample.hinge.name": "힌지 어셈블리",
"sample.plate.about": "필렛, 구멍, 보어 가공된 보스가 있는 매개변수 기반 플레이트",
"sample.plate.name": "샘플 플레이트",
"sample.vents.about": "직사각형 패턴의 슬롯: 행, 열, 피치가 매개변수",
"sample.vents.name": "통풍 플레이트",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
"settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).",
"settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음",
@@ -34,6 +66,147 @@
"ui.active_component": "활성 컴포넌트",
"ui.agent_cursor": "에이전트 커서",
"ui.agent_cursor_speed": "에이전트 커서 속도",
"ui.agent_chat": "에이전트 채팅",
"ui.agent_chat_command": "에이전트 명령",
"ui.agent_chat_desktop_only": "에이전트 채팅은 데스크톱 앱이 필요합니다.",
"ui.agent_chat_side": "에이전트 채팅 위치",
"ui.agent_chat_left": "왼쪽",
"ui.agent_chat_right": "오른쪽",
"ui.agent_chat_turn_limit": "메시지당 변경 수",
"ui.agent_chat_privacy_note": "SolidCraft는 아무것도 어디로도 보내지 않습니다. 실행한 에이전트가 사용자의 계정으로 자신의 서비스와 통신합니다. SolidCraft는 키나 비밀번호를 보거나 저장하지 않습니다.",
"ui.agent_chat_connect": "연결",
"ui.agent_chat_stop": "중지",
"ui.agent_chat_stop_tip": "에이전트와 에이전트가 시작한 모든 것을 중지",
"ui.agent_chat_close": "닫기",
"ui.agent_chat_minimize": "얇은 막대로 최소화",
"ui.agent_chat_dock_left": "왼쪽에 고정",
"ui.agent_chat_dock_right": "오른쪽에 고정",
"ui.agent_chat_send": "보내기",
"ui.agent_chat_cancel": "취소",
"ui.agent_chat_refresh": "새로 고침",
"ui.agent_chat_pick": "에이전트",
"ui.agent_chat_custom_hint": "표준 입출력으로 Agent Client Protocol을 사용하는 모든 프로그램. 셸 없이 실행됩니다.",
"ui.agent_chat_not_installed": "설치되지 않음",
"ui.agent_chat_none_found": "코딩 에이전트를 찾지 못했습니다. 다음 중 하나를 설치한 뒤 새로 고침을 누르세요:",
"ui.agent_chat_detecting": "에이전트 찾는 중…",
"ui.agent_chat_signed_in": "로그인됨",
"ui.agent_chat_key_in_env": "키 설정됨",
"ui.agent_chat_not_signed_in": "로그인 안 됨",
"ui.agent_chat_state_off": "연결 안 됨",
"ui.agent_chat_state_starting": "시작하는 중…",
"ui.agent_chat_state_ready": "준비됨",
"ui.agent_chat_state_busy": "작업 중…",
"ui.agent_chat_state_auth": "로그인 필요",
"ui.agent_chat_state_exited": "에이전트 중지됨",
"ui.agent_chat_changes_of": "변경 {n}/{limit}",
"ui.agent_chat_auth_title": "에이전트에 로그인",
"ui.agent_chat_auth_needed": "에이전트가 로그인을 요청합니다. 로그인은 에이전트에서 직접 하며, SolidCraft는 비밀번호나 키를 보지 않습니다.",
"ui.agent_chat_auth_none": "이 에이전트는 여기서 로그인 방법을 제공하지 않습니다. 에이전트 문서에 안내된 방법으로 로그인하세요.",
"ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:",
"ui.agent_chat_auth_agent": "{name}(으)로 로그인",
"ui.agent_chat_auth_env": "환경 변수 {vars}을(를) 설정하고 SolidCraft를 다시 시작하세요.",
"ui.agent_chat_auth_retry": "로그인했습니다: 다시 연결",
"ui.agent_chat_copy": "복사",
"ui.agent_chat_permission": "에이전트가 실행 허락을 요청합니다: {title}",
"ui.agent_chat_answered": "응답: {option}",
"ui.agent_chat_approval": "에이전트가 이 작업을 하도록 허용할까요?",
"ui.agent_chat_allow": "허용",
"ui.agent_chat_deny": "거부",
"ui.agent_chat_allowed": "허용함",
"ui.agent_chat_denied": "거부함",
"ui.agent_chat_changes": "변경 {n}개, 실행 취소 한 번",
"ui.agent_chat_undo_changes": "이 변경 사항 실행 취소",
"ui.agent_chat_undone": "(취소됨)",
"ui.agent_chat_undo_label": "에이전트 채팅: {message}",
"ui.agent_chat_limit": "에이전트가 메시지당 변경 한도 {limit}에 도달했습니다. 계속하게 하려면 메시지를 하나 더 보내세요.",
"ui.agent_chat_refused": "에이전트에게 허용되지 않음: {reason}",
"ui.agent_chat_wait_turn": "에이전트가 아직 이전 메시지를 처리 중입니다. 기다리거나 취소를 누르세요.",
"ui.agent_chat_placeholder": "에이전트에게 디자인 변경을 요청하세요…",
"ui.agent_chat_attach_view": "뷰 첨부",
"ui.agent_chat_attach_view_tip": "현재 뷰의 이미지를 메시지와 함께 보냅니다",
"ui.agent_chat_no_images": "이 에이전트는 이미지를 받지 않습니다",
"ui.agent_chat_view_attached": "뷰 첨부됨",
"ui.agent_chat_no_cli": "앱 옆에서 solidcraft-cli를 찾지 못해 에이전트가 디자인에 접근할 수 없습니다.",
"ui.agent_chat_no_control": "에이전트 채팅 엔드포인트가 시작되지 않아 에이전트가 디자인에 접근할 수 없습니다.",
"ui.agent_chat_exited": "에이전트가 중지되었습니다.",
"ui.agent_chat_failed_start": "에이전트를 시작할 수 없습니다: {error}",
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. SolidCraft 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
"ui.agent_chat_privacy": "사용자의 계정으로 실행하는 에이전트 외에는 아무 데도 전송되지 않습니다.",
"ui.agent_chat_none_found_short": "에이전트를 찾지 못함",
"ui.ai_add": "추가",
"ui.ai_add_agent": "에이전트 추가…",
"ui.ai_add_agent_title": "에이전트 추가",
"ui.ai_add_api": "OpenAI 호환 API",
"ui.ai_add_custom": "사용자 지정 명령",
"ui.ai_added": "추가됨",
"ui.ai_add_installed": "설치된 에이전트",
"ui.ai_add_installed_note": "설치된 에이전트에 대한 별도 항목(예: 다른 인수로 실행). 설치된 에이전트는 자동으로 표시됩니다.",
"ui.ai_agent_mode_tip": "에이전트 자체 모드: 에이전트 자체 도구(셸, 파일 편집)는 이 모드를 따릅니다. SolidCraft 도구는 환경 설정 > AI의 권한을 따릅니다.",
"ui.ai_agents": "에이전트",
"ui.ai_always_allow": "항상 허용",
"ui.ai_always_allowed": "항상 허용됨",
"ui.ai_always_allowed_none": "아직 항상 허용된 것이 없습니다. 채팅에서 \"항상 허용\"으로 답한 항목이 여기에 표시됩니다.",
"ui.ai_always_allow_tip": "이 에이전트에게 앞으로 {tool}을(를) 묻지 않고 허용",
"ui.ai_auto_allowed": "자동 허용: {title} ({why})",
"ui.ai_auto_mode": "권한 설정",
"ui.ai_auto_ours": "SolidCraft가 자체 도구를 직접 확인합니다",
"ui.ai_auto_remembered": "\"항상 허용\"을 선택함",
"ui.ai_bad_url": "http:// 또는 https://로 시작하는 URL을 입력하세요",
"ui.ai_base_url": "기본 URL",
"ui.ai_button": "AI",
"ui.ai_copy_suffix": "복사본",
"ui.ai_custom_url": "사용자 지정 URL",
"ui.ai_default": "기본값",
"ui.ai_http_busy": "엔드포인트가 바쁘거나 한도에 도달했습니다. 나중에 다시 시도하세요",
"ui.ai_http_key": "엔드포인트가 키를 거부했습니다. 환경 설정 > AI에서 확인하세요",
"ui.ai_http_not_found": "엔드포인트나 모델을 찾을 수 없습니다. 기본 URL과 모델 이름을 확인하세요",
"ui.ai_key": "API 키",
"ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}",
"ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장",
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
"ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
"ui.ai_mode_all": "모두 허용",
"ui.ai_mode_all_note": "묻지 않고 실행합니다. 충돌 보고서는 계속 제외되고 폴더 제한도 그대로 적용됩니다.",
"ui.ai_mode_all_warning": "에이전트가 묻지 않고 디자인을 저장, 덮어쓰기, 내보내기, 교체할 수 있습니다. 신뢰하는 에이전트와 저장된 디자인에서만 사용하세요.",
"ui.ai_mode_ask": "매번 묻기",
"ui.ai_mode_ask_note": "디자인의 모든 변경과 파일 작업이 사용자의 허용을 기다립니다.",
"ui.ai_mode_edits": "모델 편집 자동 허용",
"ui.ai_mode_edits_note": "모델링 명령은 묻지 않고 실행되며, 저장·열기·내보내기 등 파일 작업이나 파괴적인 작업은 계속 묻습니다.",
"ui.ai_mode_global": "위 설정 따름",
"ui.ai_model": "모델",
"ui.ai_modes_scope": "이 설정은 SolidCraft 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.",
"ui.ai_name": "이름",
"ui.ai_need_command": "에이전트를 시작하는 명령을 입력하세요",
"ui.ai_need_model": "모델 이름을 입력하세요",
"ui.ai_no_answer": "에이전트가 제시간에 응답하지 않았습니다",
"ui.ai_no_http": "이 빌드는 API 엔드포인트에 연결할 수 없습니다",
"ui.ai_no_keychain": "시스템 키체인을 사용할 수 없어 키를 저장할 수 없습니다. 키가 필요 없는 로컬 서버는 사용할 수 있습니다.",
"ui.ai_panel": "패널",
"ui.ai_permissions": "권한",
"ui.ai_preset": "프리셋",
"ui.ai_remove": "제거",
"ui.ai_request_limit": "메시지 하나에 24번 요청 후 멈췄습니다. 계속하려면 메시지를 하나 더 보내세요",
"ui.ai_reset": "재설정",
"ui.ai_settings": "AI 설정…",
"ui.ai_show_features": "AI 기능 표시",
"ui.ai_show_features_note": "에이전트 채팅: 이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트가 열린 디자인을 다루는 패널입니다. 끄면 AI 버튼, 단축키, 메뉴 항목, 패널이 없고 아무것도 찾거나 시작하지 않습니다.",
"ui.ai_switch_tip": "이 대화의 에이전트 변경",
"ui.ai_test": "테스트",
"ui.ai_test_acp_ok": "응답: {agent}",
"ui.ai_test_acp_tip": "에이전트를 잠시 시작해 세션을 요청한 뒤 종료합니다",
"ui.ai_test_api_tip": "엔드포인트에 모델 목록을 요청합니다",
"ui.ai_testing": "테스트 중…",
"ui.ai_test_no_model": "연결됨: 모델 {count}개, {model} 없음",
"ui.ai_test_ok": "연결됨: 모델 {count}개",
"ui.ai_toolbar_button": "도구 모음에 버튼 표시",
"ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.",
"ui.ai_tool_done": "완료",
"ui.ai_tool_failed": "실패",
"ui.ai_tool_running": "실행 중",
"ui.ai_tool_unfold": "클릭하면 결과를 표시",
"ui.ai_tool_waiting": "대기 중",
"ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소",
"AI": "AI",
"ui.all": "모두",
"ui.analysis": "분석",
"ui.angle": "각도",
@@ -42,6 +215,16 @@
"ui.animation": "애니메이션",
"ui.animation_speed": "애니메이션 속도",
"ui.anti_aliasing": "안티앨리어싱",
"ui.supersampling": "슈퍼샘플링 (SSAA)",
"ui.supersampling_off": "끔 (1×)",
"ui.supersampling_adaptive": "뷰가 멈춰 있을 때만",
"settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.",
"ui.display_quality": "표시 품질",
"ui.display_quality_coarse": "거침",
"ui.display_quality_normal": "보통",
"ui.display_quality_fine": "세밀",
"ui.display_quality_ultra": "최고",
"settings.display_quality_note": "세밀할수록 곡면과 모서리가 더 매끄럽게 표시되지만 메시 생성 시간이 늘어납니다. 뷰포트만 바뀌며 설계나 내보내기는 바뀌지 않습니다.",
"ui.apply": "적용",
"ui.arc": "호",
"ui.assemble": "조립",
@@ -68,10 +251,47 @@
"ui.clear_recent": "최근 항목 지우기",
"ui.clear_the_selection": "선택 해제",
"ui.close": "닫기",
"ui.material_review.title": "물리적 재료 데이터 검토",
"ui.material_review.scope": "후보만 검토합니다. 지정하거나 저장하지 않습니다. 수치 검증은 공학적 인증이 아닙니다.",
"ui.material_review.input": "스키마 1 재료 카드 붙여넣기 (JSON)",
"ui.material_review.review": "카드 검토",
"ui.material_review.limit": "카드가 256 KiB 입력 한도를 초과합니다.",
"ui.material_review.unknown": "알 수 없음",
"ui.material_review.properties": "물성",
"ui.material_review.conditions": "명시된 조건",
"ui.material_review.source": "출처",
"ui.material_review.axes": "재료 로컬 축입니다. 바디 방향을 지정하지 않습니다.",
"ui.material_review.derived": "유도된 등방성 탄성 계수",
"ui.material_review.claims": "출처와 라이선스는 검증되지 않은 주장입니다. 누락된 값은 알 수 없는 상태로 유지됩니다.",
"ui.material_review.density": "밀도",
"ui.material_review.elasticity": "탄성",
"ui.material_review.tensile_strength": "인장 강도",
"ui.material_review.compressive_strength": "압축 강도",
"ui.material_review.yield_strength": "항복 강도",
"ui.material_review.shear_strength": "전단 강도",
"ui.material_review.thermal_conductivity": "열전도율",
"ui.material_review.thermal_expansion": "열팽창",
"ui.material_review.specific_heat": "비열",
"ui.material_review.moisture_unit": "kg 물 / kg 건조 재료",
"ui.coil": "코일",
"ui.coincident": "일치",
"ui.collapse": "접기",
"ui.collinear": "동일 선상",
"ui.colour_vision": "색각",
"ui.colour_vision_deutan": "녹색각이상 (Deutan)",
"ui.colour_vision_deutan_tip": "녹색 감각이 약하거나 없는 경우",
"ui.colour_vision_failed": "실패",
"ui.colour_vision_fixed": "고정",
"ui.colour_vision_mode": "모드",
"ui.colour_vision_note": "의미를 나타내는 색을 바꿉니다: 스케치 상태, 축, 벤딩, 분석, 아이콘. Windows 색 필터를 사용한다면 '끄기'로 두세요.",
"ui.colour_vision_off": "끄기",
"ui.colour_vision_off_tip": "테마 고유의 색상",
"ui.colour_vision_projected": "투영",
"ui.colour_vision_protan": "적색각이상 (Protan)",
"ui.colour_vision_protan_tip": "빨간색 감각이 약하거나 없는 경우",
"ui.colour_vision_sample": "예시",
"ui.colour_vision_tritan": "청색각이상 (Tritan)",
"ui.colour_vision_tritan_tip": "파란색 감각이 약하거나 없는 경우",
"ui.combine": "결합",
"ui.coming_later": "추후 지원 예정",
"ui.concentric": "동심",
@@ -265,6 +485,10 @@
"ui.quantity": "수량",
"ui.quit": "종료",
"ui.radius": "반지름",
"ui.pull_direction": "빼기 방향",
"ui.tool_direction": "공구 방향",
"ui.reachable": "도달 가능",
"ui.blocked": "막힘",
"ui.radius_type": "반지름 유형",
"ui.rectangle": "직사각형",
"ui.rectangular_pattern": "직사각형 패턴",
@@ -276,9 +500,33 @@
"ui.revolve": "회전",
"ui.right": "우측면",
"ui.save": "저장",
"ui.heading_save_mesh": "메쉬로 저장",
"ui.format": "형식",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "면 태그가 있는 3MF",
"ui.send_to_slicer": "슬라이서로 보내기",
"ui.slicer": "슬라이서",
"ui.custom_program": "사용자 지정…",
"ui.no_slicer_found": "슬라이서를 찾을 수 없음",
"ui.send": "보내기",
"ui.custom_program_tip": "목록에 추가할 슬라이서 프로그램 선택",
"ui.slicer_delete_tip": "{program}\nDelete 키로 목록에서 제거",
"ui.save_mesh_pick_bodies": "저장할 바디를 선택하세요",
"ui.save_mesh_no_slicer": "슬라이서를 찾을 수 없습니다. '사용자 지정…'에서 슬라이서 프로그램을 선택하거나 '슬라이서로 보내기'를 끄세요",
"ui.not_a_program": "{path}은(는) 실행할 수 있는 프로그램이 아닙니다",
"ui.sent_to_slicer": "{slicer}(으)로 보냈습니다",
"ui.saved_to": "저장됨: {path}",
"ui.save_mesh_temp_failed": "{folder}을(를) 만들 수 없습니다: {error}",
"ui.save_as": "다른 이름으로 저장",
"ui.save_as_ellipsis": "다른 이름으로 저장...",
"ui.scale": "축척",
"ui.orientation": "배향",
"ui.twist": "비틀림",
"ui.end_scale": "끝 배율",
"ui.sweep_turn_with_path": "경로를 따라 회전",
"ui.sweep_keep_as_drawn": "그린 대로 유지",
"ui.sweep_follow_direction": "방향 따르기",
"ui.section_analysis": "단면 분석",
"ui.select": "선택",
"ui.selection": "선택",
@@ -366,6 +614,17 @@
"ui.pinToShortcuts": "바로 가기에 고정",
"ui.pinToToolbar": "도구 모음에 고정",
"ui.properties": "속성",
"ui.design_properties.project": "프로젝트",
"ui.design_properties.part_number": "부품 번호",
"ui.design_properties.revision": "리비전",
"ui.design_properties.author": "작성자",
"ui.design_properties.organization": "조직",
"ui.design_properties.description": "설명",
"ui.design_properties.license": "라이선스",
"ui.design_properties.not_text": "텍스트가 아닌 값으로 저장되어 있습니다. 여기에 입력하면 대체됩니다.",
"ui.design_properties.exports": "STEP, IGES, 3MF 내보내기는 형식에 해당 항목이 있으면 이 속성을 기록합니다.",
"ui.design_properties.others": "다른 도구의 데이터",
"ui.design_properties.others_note": "설계에 그대로 유지되며 내보내지지 않습니다.",
"ui.removeSection": "단면 제거",
"ui.removeFromToolbar": "도구 모음에서 제거",
"ui.repeat": "반복",
@@ -389,6 +648,9 @@
"ui.updateToCurrentView": "현재 보기로 업데이트",
"ui.zoom": "확대/축소",
"ui.command_done": "{command} 완료",
"drive.stopped_by_loop": "{value}에서 멈춤: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다",
"drive.stopped_by_contact": "{value}에서 멈춤: {a}이(가) {b}을(를) 통과하게 됩니다",
"drive.unconnected": "아무것도 움직이지 않습니다: {part}이(가) 고정된 것에 연결되어 있지 않습니다",
"cmd.appearance_assign": "외관",
"cmd.appearance_library": "외관 라이브러리",
"cmd.appearance_list": "외관 목록",
@@ -409,6 +671,7 @@
"cmd.component_capture_position": "위치 캡처",
"cmd.component_create": "새 구성 요소",
"cmd.component_delete": "구성 요소 삭제",
"cmd.component_unlock": "내용 잠금 해제",
"cmd.component_from_bodies": "바디로 구성 요소 만들기",
"cmd.component_insert_design": "설계 삽입",
"cmd.component_links": "링크 목록",
@@ -461,6 +724,9 @@
"cmd.doc_recovery_list": "복구 가능한 설계",
"cmd.document_inspect": "설계 검사",
"cmd.document_units": "설계 단위 변경",
"cmd.document_metadata": "설계 속성",
"cmd.document_metadata_set": "설계 속성 변경",
"cmd.document_metadata_remove": "설계 속성 제거",
"cmd.edit_checkpoint": "체크포인트",
"cmd.edit_restore_checkpoint": "체크포인트 복원",
"cmd.engine_commands": "명령 목록",
@@ -474,8 +740,10 @@
"cmd.file_insert_mesh": "메쉬 삽입",
"cmd.file_insert_step": "STEP 삽입",
"cmd.file_save_mesh": "메쉬로 저장",
"cmd.file_slicers": "설치된 슬라이서",
"cmd.inspect_accessibility": "접근성 분석",
"cmd.inspect_center_of_mass": "무게중심",
"cmd.inspect_compare": "바디 비교",
"cmd.inspect_curvature_comb": "곡률빗 분석",
"cmd.inspect_curvature_map": "곡률맵 분석",
"cmd.inspect_draft": "블록각 분석",
@@ -522,6 +790,7 @@
"cmd.parameters_delete": "매개 변수 삭제",
"cmd.parameters_export": "매개 변수 내보내기",
"cmd.parameters_favorite": "즐겨찾는 매개 변수",
"cmd.parameters_move": "매개 변수 이동",
"cmd.parameters_graph": "매개 변수 그래프",
"cmd.parameters_import": "매개 변수 가져오기",
"cmd.parameters_list": "매개 변수 목록",
@@ -571,6 +840,8 @@
"cmd.sketch_constraint_fix": "고정/고정 해제",
"cmd.sketch_constraint_midpoint": "중간점",
"cmd.sketch_construction": "일반/구성선",
"cmd.sketch_copy": "스케치 요소 복사",
"cmd.sketch_cut": "스케치 요소 잘라내기",
"cmd.sketch_delete": "스케치 개체 삭제",
"cmd.sketch_dimension_text": "치수 텍스트 이동",
"cmd.sketch_drag": "스케치 기하 드래그",
@@ -595,6 +866,7 @@
"cmd.sketch_move_point": "스케치 점 드래그",
"cmd.sketch_offset_break": "오프셋 연결 끊기",
"cmd.sketch_options": "스케치 팔레트 옵션",
"cmd.sketch_paste": "스케치 요소 붙여넣기",
"cmd.sketch_point3d": "3D 점",
"cmd.sketch_polygon_circumscribed": "외접 다각형",
"cmd.sketch_polygon_edge": "변 다각형",
@@ -668,9 +940,12 @@
"ui.heading_constraints": "구속 조건",
"ui.heading_utilities": "유틸리티",
"BROWSER": "브라우저",
"Bends": "벤딩",
"Drive Joint": "조인트 구동",
"Edit Profile Sketch": "윤곽 스케치 편집",
"Find in Browser": "브라우저에서 찾기",
"Copy Error Message": "오류 메시지 복사",
"Copy Warning Message": "경고 메시지 복사",
"Report a bug — review a screenshot and open a GitHub draft": "버그 신고 — 스크린샷을 검토하고 GitHub 초안 열기",
"Reset Toolbar": "도구 모음 재설정",
"Select bodies (a face picks its body)": "바디 선택(면을 클릭하면 해당 바디 선택)",
@@ -721,6 +996,18 @@
"Profiles/Curves": "프로파일/곡선",
"update.button": "업데이트",
"update.button_tip": "SolidCraft {version} 사용 가능",
"install.menu": "앱 설치…",
"install.button_tip": "SolidCraft를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다",
"install.title": "SolidCraft 설치",
"install.steps_intro": "Safari는 자체 메뉴에서 웹 앱을 설치합니다:",
"install.home_screen_1": "공유를 탭합니다(도구 막대에 없으면 … 메뉴 안에 있습니다).",
"install.home_screen_2": "홈 화면에 추가를 선택합니다.",
"install.home_screen_3": "추가를 탭합니다.",
"install.home_screen_after": "이후 SolidCraft는 홈 화면의 아이콘에서 Safari 도구 막대 없이 열리며 오프라인에서도 작동합니다.",
"install.dock_1": "파일 › Dock에 추가를 선택합니다.",
"install.dock_2": "추가를 클릭합니다.",
"install.dock_after": "이후 SolidCraft는 Dock에서 별도 창으로 열리며 오프라인에서도 작동합니다.",
"install.done": "완료",
"update.title": "업데이트 사용 가능",
"update.available": "SolidCraft {version} 사용 가능",
"update.you_have": "현재 버전은 {version}입니다.",
@@ -752,5 +1039,88 @@
"update.restart_later": "나중에",
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다."
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다.",
"cmd.render_settings_get": "렌더링 설정",
"cmd.render_settings_set": "렌더링 설정 저장",
"cmd.render_scene": "렌더링 장면 검사",
"cmd.render_settings_apply": "렌더링 카메라 적용",
"toolbar.render": "렌더링",
"toolbar.render_scene": "장면",
"render.preview": "통합 미리보기",
"render.preview_help": "래스터 셰이딩과 블렌딩 투명도를 사용합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.",
"render.setup_name": "설정 이름",
"render.background": "장면 배경",
"render.perspective": "원근 카메라",
"render.fit": "제품에 맞추기",
"render.restore_camera": "저장된 카메라 복원",
"render.save_setup": "설정 저장",
"render.unsaved": "저장하지 않은 미리보기 변경 사항",
"render.reload_settings": "저장된 설정 다시 불러오기",
"render.unavailable_preview": "이 설정은 사용할 수 없는 미리보기 프로필을 요청합니다.",
"render.use_preview": "통합 미리보기 사용",
"render.navigation_help": "카메라는 디자인에서와 같이 움직입니다(환경설정 › 탐색). 설정 저장은 현재 카메라를 기록합니다.",
"render.return_design": "설계로 돌아가기",
"render.image_title": "이미지 렌더링",
"render.image_help": "CPU 미리 보기 이미지 · PNG · 근사 투명도",
"render.image_width": "너비",
"render.image_height": "높이",
"render.image_transparent": "투명 배경",
"render.image_check": "렌더 요청 확인",
"render.image_supported": "지원되는 렌더 요청입니다. 시작 시 대기열과 메모리 풀을 다시 확인합니다.",
"render.image_queued": "렌더링 대기 중",
"render.image_preparing": "이미지 준비 중",
"render.image_running": "이미지 렌더링 중",
"render.image_encoding": "PNG 인코딩 중",
"render.image_cancelling": "취소 중",
"render.image_cancelled": "렌더링 취소됨",
"render.image_completed": "이미지 내보내기 준비됨",
"render.image_failed": "렌더링 실패",
"render.image_unknown": "렌더 상태를 확인할 수 없습니다",
"render.image_reduce": "더 작은 이미지나 더 단순한 장면을 사용해 보세요.",
"render.image_too_large": "이미지 한도를 초과했습니다: 한 변 4096픽셀, 전체 4,194,304픽셀.",
"render.image_use_largest": "이 비율에서 가장 큰 크기 사용:",
"render.image_cancel": "렌더링 취소",
"render.image_stale": "이 이미지는 후속 변경 전에 캡처한 장면을 사용합니다.",
"render.image_start": "렌더링 시작",
"render.image_overwrite": "기존 파일 바꾸기",
"render.image_export": "PNG 내보내기",
"cmd.render_preflight": "렌더 요청 확인",
"cmd.render_start": "이미지 렌더링",
"cmd.render_status": "렌더링 진행 상황",
"cmd.render_cancel": "렌더링 취소",
"cmd.render_export": "PNG 내보내기",
"toolbar.image": "이미지",
"appearance.surface_response": "표면 반응",
"appearance.pbr_opaque": "불투명 PBR",
"appearance.metallic": "금속성",
"appearance.roughness": "거칠기",
"appearance.dielectric_ior": "유전체 굴절률",
"appearance.pbr_help": "이 표면은 렌더링 작업 공간이나 제품 PNG에서 확인하세요. 굴절과 투과는 지원하지 않습니다.",
"render.opaque_preview_help": "불투명 PBR 및 단색 표시, 블렌딩 투명도를 지원합니다. 굴절, HDRI 및 최종 렌더링은 아직 지원하지 않습니다.",
"render.opaque_image_summary": "미리보기 PNG · 불투명 PBR · 블렌딩 투명도",
"UNTRIM": "트림 해제",
"Face": "면",
"Holes": "구멍",
"Holes (edges on them)": "구멍 (구멍의 모서리)",
"In place (surfaces)": "제자리에서 (서피스)",
"None": "없음",
"Picked": "선택한 항목",
"Restore outer boundary": "외부 경계 복원",
"RULED SURFACE": "룰드 서피스",
"Mitre": "연귀",
"Reference face": "참조 면",
"Round": "둥글게",
"Type": "유형",
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.",
"Circumference": "원주",
"Arc length": "호 길이",
"Slot width": "슬롯 폭",
"Centre to centre": "중심 간",
"Overall length": "전체 길이",
"Min": "최소",
"Max": "최대",
"Length": "길이",
"Area": "면적"
}
+186 -1
View File
@@ -25,6 +25,7 @@
"cmd.component_capture_position": "Зафиксировать положение",
"cmd.component_create": "Новый компонент",
"cmd.component_delete": "Удалить компонент",
"cmd.component_unlock": "Разблокировать содержимое",
"cmd.component_from_bodies": "Создать компоненты из тел",
"cmd.component_insert_design": "Вставить дизайн",
"cmd.component_links": "Список связей",
@@ -77,6 +78,9 @@
"cmd.doc_recovery_list": "Восстановимые дизайны",
"cmd.document_inspect": "Инспекция дизайна",
"cmd.document_units": "Изменить единицы дизайна",
"cmd.document_metadata": "Свойства дизайна",
"cmd.document_metadata_set": "Изменить свойства дизайна",
"cmd.document_metadata_remove": "Удалить свойство дизайна",
"cmd.edit_checkpoint": "Контрольная точка",
"cmd.edit_restore_checkpoint": "Вернуть контрольную точку",
"cmd.engine_commands": "Список команд",
@@ -90,8 +94,10 @@
"cmd.file_insert_mesh": "Вставить меш",
"cmd.file_insert_step": "Вставить STEP",
"cmd.file_save_mesh": "Сохранить как меш",
"cmd.file_slicers": "Установленные слайсеры",
"cmd.inspect_accessibility": "Анализ доступности",
"cmd.inspect_center_of_mass": "Центр масс",
"cmd.inspect_compare": "Сравнить тела",
"cmd.inspect_curvature_comb": "Анализ кривизны (гребёнка)",
"cmd.inspect_curvature_map": "Карта кривизны",
"cmd.inspect_draft": "Анализ уклонов",
@@ -136,6 +142,7 @@
"cmd.parameters_delete": "Удалить параметр",
"cmd.parameters_export": "Экспортировать параметры",
"cmd.parameters_favorite": "Избранный параметр",
"cmd.parameters_move": "Переместить параметр",
"cmd.parameters_graph": "График параметра",
"cmd.parameters_import": "Импортировать параметры",
"cmd.parameters_list": "Список параметров",
@@ -184,6 +191,8 @@
"cmd.sketch_constraint_fix": "Зафиксировать/Освободить",
"cmd.sketch_constraint_midpoint": "Середина",
"cmd.sketch_construction": "Обычная/Вспомогательная",
"cmd.sketch_copy": "Копировать элементы эскиза",
"cmd.sketch_cut": "Вырезать элементы эскиза",
"cmd.sketch_delete": "Удалить объекты эскиза",
"cmd.sketch_dimension_text": "Переместить текст размера",
"cmd.sketch_drag": "Перетащить геометрию эскиза",
@@ -208,6 +217,7 @@
"cmd.sketch_move_point": "Перетащить точку эскиза",
"cmd.sketch_offset_break": "Разорвать связь смещения",
"cmd.sketch_options": "Параметры палитры эскиза",
"cmd.sketch_paste": "Вставить элементы эскиза",
"cmd.sketch_point3d": "3D-точка",
"cmd.sketch_polygon_circumscribed": "Описанный многоугольник",
"cmd.sketch_polygon_edge": "Многоугольник по ребру",
@@ -284,6 +294,31 @@
"dialog.selection_guidance": "Щёлкните поле ввода, чтобы изменить его. Щелчок по выделенному объекту убирает его; Ctrl/Cmd+щелчок тоже переключает. Проверьте предпросмотр и нажмите OK.",
"document.save_changes": "Сохранить изменения в {name}?",
"document.unsaved_changes": "В {name} есть несохранённые изменения. Если их не сохранить, они будут потеряны.",
"home.documentation": "Документация",
"home.in_this_browser": "в этом браузере",
"home.missing": "отсутствует",
"home.open": "Открыть…",
"home.opened_days_ago": "Открыт {count} дн. назад",
"home.opened_hours_ago": "Открыт {count} ч назад",
"home.opened_just_now": "Открыт только что",
"home.opened_minutes_ago": "Открыт {count} мин назад",
"home.opening": "открывается…",
"home.recent": "Недавние",
"home.report_issue": "Сообщить о проблеме",
"home.samples": "Примеры",
"home.whats_new": "Что нового",
"sample.bracket.about": "Базовый фланец с краевыми фланцами и кромкой по правилу листового металла 2 мм",
"sample.bracket.name": "Кронштейн из листового металла",
"sample.enclosure.about": "Тонкостенный бокс с выступом, бобышками под винты, опорой и защёлкой",
"sample.enclosure.name": "Пластиковый корпус",
"sample.flange.about": "Ступица и фланец с круговым массивом, управляемым параметром",
"sample.flange.name": "Болтовой фланец",
"sample.hinge.about": "Два компонента и вращательное сопряжение, повёрнутое на 60°",
"sample.hinge.name": "Сборка шарнира",
"sample.plate.about": "Параметрическая пластина со скруглениями, отверстиями и расточенной бобышкой",
"sample.plate.name": "Образец пластины",
"sample.vents.about": "Пазы в прямоугольном массиве: число рядов, столбцов и шаг заданы параметрами",
"sample.vents.name": "Вентиляционная пластина",
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft",
"settings.msaa_reset": "{setting}: {requested}× не поддерживается этим графическим устройством; теперь {reset}× (поддерживается: {supported}).",
"settings.msaa_unsupported": "Не поддерживается этим графическим устройством",
@@ -338,10 +373,47 @@
"ui.click_selects_whole_bodies": "Щелчок выделяет тела целиком",
"ui.click_then_press_key": "Щёлкните, затем нажмите новую клавишу",
"ui.close": "Закрыть",
"ui.material_review.title": "Проверка физических данных материала",
"ui.material_review.scope": "Только кандидат: без назначения и сохранения. Численная проверка не является инженерной сертификацией.",
"ui.material_review.input": "Вставьте карточку материала по схеме 1 (JSON)",
"ui.material_review.review": "Проверить карточку",
"ui.material_review.limit": "Карточка превышает предел ввода 256 КиБ.",
"ui.material_review.unknown": "Неизвестно",
"ui.material_review.properties": "Свойства",
"ui.material_review.conditions": "Указанные условия",
"ui.material_review.source": "Источник",
"ui.material_review.axes": "Локальные оси материала; ориентация тела не назначается.",
"ui.material_review.derived": "Вычисленные изотропные модули",
"ui.material_review.claims": "Источники и лицензии указаны без проверки. Отсутствующие значения остаются неизвестными.",
"ui.material_review.density": "Плотность",
"ui.material_review.elasticity": "Упругость",
"ui.material_review.tensile_strength": "Прочность при растяжении",
"ui.material_review.compressive_strength": "Прочность при сжатии",
"ui.material_review.yield_strength": "Предел текучести",
"ui.material_review.shear_strength": "Прочность при сдвиге",
"ui.material_review.thermal_conductivity": "Теплопроводность",
"ui.material_review.thermal_expansion": "Тепловое расширение",
"ui.material_review.specific_heat": "Удельная теплоёмкость",
"ui.material_review.moisture_unit": "кг воды / кг сухого материала",
"ui.coil": "Спираль",
"ui.coincident": "Совпадение",
"ui.collapse": "Свернуть",
"ui.collinear": "Коллинеарность",
"ui.colour_vision": "Цветовое зрение",
"ui.colour_vision_deutan": "Дейтан",
"ui.colour_vision_deutan_tip": "При слабом или отсутствующем восприятии зелёного",
"ui.colour_vision_failed": "Ошибка",
"ui.colour_vision_fixed": "Фиксировано",
"ui.colour_vision_mode": "Режим",
"ui.colour_vision_note": "Меняет цвета, которые несут смысл: статус эскиза, оси, гибы, анализы и значки. Если вы пользуетесь цветовыми фильтрами Windows, оставьте «Выключено».",
"ui.colour_vision_off": "Выключено",
"ui.colour_vision_off_tip": "Собственные цвета темы",
"ui.colour_vision_projected": "Проекция",
"ui.colour_vision_protan": "Протан",
"ui.colour_vision_protan_tip": "При слабом или отсутствующем восприятии красного",
"ui.colour_vision_sample": "Пример",
"ui.colour_vision_tritan": "Тритан",
"ui.colour_vision_tritan_tip": "При слабом или отсутствующем восприятии синего",
"ui.combine": "Объединение",
"ui.coming_later": "появится позже",
"ui.concentric": "Концентричность",
@@ -567,9 +639,33 @@
"ui.revolve": "Вращение",
"ui.right": "Справа",
"ui.save": "Сохранить",
"ui.heading_save_mesh": "СОХРАНИТЬ КАК МЕШ",
"ui.format": "Формат",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF с метками граней",
"ui.send_to_slicer": "Отправить в слайсер",
"ui.slicer": "Слайсер",
"ui.custom_program": "Другая…",
"ui.no_slicer_found": "Слайсер не найден",
"ui.send": "Отправить",
"ui.custom_program_tip": "Выберите программу-слайсер для добавления в список",
"ui.slicer_delete_tip": "{program}\nНажмите Delete, чтобы убрать из списка",
"ui.save_mesh_pick_bodies": "Выберите тела для сохранения",
"ui.save_mesh_no_slicer": "Слайсер не найден: выберите «Другая…» и укажите программу или отключите «Отправить в слайсер»",
"ui.not_a_program": "{path} — не программа, которую можно запустить",
"ui.sent_to_slicer": "Отправлено в {slicer}",
"ui.saved_to": "Сохранено: {path}",
"ui.save_mesh_temp_failed": "Не удалось создать {folder}: {error}",
"ui.save_as": "Сохранить как",
"ui.save_as_ellipsis": "Сохранить как...",
"ui.scale": "Масштаб",
"ui.orientation": "Ориентация",
"ui.twist": "Закрутка",
"ui.end_scale": "Масштаб в конце",
"ui.sweep_turn_with_path": "Поворачивать по траектории",
"ui.sweep_keep_as_drawn": "Как нарисовано",
"ui.sweep_follow_direction": "Следовать направлению",
"ui.section_analysis": "Анализ сечения",
"ui.select": "Выбрать",
"ui.select_other_hint": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект.",
@@ -721,6 +817,8 @@
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Файлы многих CAD-программ ориентированы Y вверх, поэтому здесь они открываются лёжа на спине (Z вверх).",
"Filter": "Фильтр",
"Find in Browser": "Найти в браузере",
"Copy Error Message": "Скопировать сообщение об ошибке",
"Copy Warning Message": "Скопировать предупреждение",
"Flat size": "Размер развёртки",
"Follow camera (Look At sketches)": "Следовать за камерой (эскизы «Взгляд на»)",
"Font": "Шрифт",
@@ -909,10 +1007,33 @@
"ui.pinToShortcuts": "Закрепить в сочетаниях клавиш",
"ui.pinToToolbar": "Закрепить на панели",
"ui.properties": "Свойства",
"ui.design_properties.project": "Проект",
"ui.design_properties.part_number": "Номер детали",
"ui.design_properties.revision": "Редакция",
"ui.design_properties.author": "Автор",
"ui.design_properties.organization": "Организация",
"ui.design_properties.description": "Описание",
"ui.design_properties.license": "Лицензия",
"ui.design_properties.not_text": "Сохранено не как текст; ввод здесь заменит значение.",
"ui.design_properties.exports": "Экспорт в STEP, IGES и 3MF записывает эти свойства, если в формате для них есть поле.",
"ui.design_properties.others": "Данные других программ",
"ui.design_properties.others_note": "Хранятся в дизайне без изменений и никогда не экспортируются.",
"ui.removeSection": "Убрать сечение",
"ui.removeFromToolbar": "Убрать с панели",
"ui.repeat": "Повторить",
"ui.report_a_bug": "Сообщить об ошибке",
"install.menu": "Установить приложение…",
"install.button_tip": "Установить SolidCraft как приложение: отдельное окно и работа без сети",
"install.title": "Установка SolidCraft",
"install.steps_intro": "Safari устанавливает веб-приложения через собственные меню:",
"install.home_screen_1": "Нажмите «Поделиться» (в меню …, если кнопки нет на панели).",
"install.home_screen_2": "Выберите «На экран „Домой“».",
"install.home_screen_3": "Нажмите «Добавить».",
"install.home_screen_after": "После этого SolidCraft открывается значком на экране «Домой», без панелей Safari, и работает без сети.",
"install.dock_1": "Выберите «Файл» › «Добавить в Dock».",
"install.dock_2": "Нажмите «Добавить».",
"install.dock_after": "После этого SolidCraft открывается из Dock в отдельном окне и работает без сети.",
"install.done": "Готово",
"ui.restore": "Восстановить",
"ui.heading_setup": "НАСТРОЙКА",
"ui.selectOther": "Выбрать другое…",
@@ -932,6 +1053,9 @@
"ui.updateToCurrentView": "Обновить до текущего вида",
"ui.zoom": "Масштаб",
"ui.command_done": "{command} — готово",
"drive.stopped_by_loop": "Остановлено на {value}: контур через {joint} дальше не замыкается",
"drive.stopped_by_contact": "Остановлено на {value}: {a} прошёл бы сквозь {b}",
"drive.unconnected": "Ничего не движется: {part} не соединён ни с чем закреплённым",
"timeline.group": "Новая папка операций",
"timeline.ungroup": "Убрать папки операций",
"timeline.rename_group": "Переименовать папку операций",
@@ -957,5 +1081,66 @@
"Line Spacing": "Межстрочный интервал",
"Path": "Траектория",
"sketch_text.on_path": "Текст следует по траектории: кривая задаёт его положение",
"cmd.material_properties_validate": "Проверить данные физического материала"
"cmd.material_properties_validate": "Проверить данные физического материала",
"cmd.render_settings_get": "Настройки рендеринга",
"cmd.render_settings_set": "Сохранить настройки рендеринга",
"cmd.render_scene": "Просмотреть сцену рендеринга",
"cmd.render_settings_apply": "Применить камеру рендеринга",
"toolbar.render": "РЕНДЕРИНГ",
"toolbar.render_scene": "СЦЕНА",
"render.preview": "Встроенный предпросмотр",
"render.preview_help": "Растровое затенение и смешанная прозрачность. Преломление, HDRI и финальный рендеринг пока недоступны.",
"render.setup_name": "Имя набора настроек",
"render.background": "Фон сцены",
"render.perspective": "Перспективная камера",
"render.fit": "Вписать модель",
"render.restore_camera": "Восстановить сохранённую камеру",
"render.save_setup": "Сохранить набор",
"render.unsaved": "Несохранённые изменения предпросмотра",
"render.reload_settings": "Загрузить сохранённые настройки",
"render.unavailable_preview": "Этот набор запрашивает недоступный профиль предпросмотра.",
"render.use_preview": "Использовать встроенный предпросмотр",
"render.navigation_help": "Камера управляется так же, как в проектировании (Настройки › Навигация). Сохранение набора записывает текущую камеру.",
"render.return_design": "Вернуться к проектированию",
"render.image_title": "Рендеринг изображения",
"render.image_help": "Предпросмотр на CPU · PNG · приближённая прозрачность",
"render.image_width": "Ширина",
"render.image_height": "Высота",
"render.image_transparent": "Прозрачный фон",
"render.image_check": "Проверить запрос рендеринга",
"render.image_supported": "Запрос поддерживается; при запуске очередь и пул памяти будут проверены снова.",
"render.image_queued": "Ожидание рендеринга",
"render.image_preparing": "Подготовка изображения",
"render.image_running": "Рендеринг изображения",
"render.image_encoding": "Кодирование PNG",
"render.image_cancelling": "Отмена",
"render.image_cancelled": "Рендеринг отменён",
"render.image_completed": "Изображение готово к экспорту",
"render.image_failed": "Ошибка рендеринга",
"render.image_unknown": "Состояние рендеринга недоступно",
"render.image_reduce": "Попробуйте уменьшить изображение или упростить сцену.",
"render.image_too_large": "Превышен предел изображения: 4096 пикселей по стороне и 4 194 304 пикселя всего.",
"render.image_use_largest": "Использовать наибольший размер с этим соотношением сторон:",
"render.image_cancel": "Отменить рендеринг",
"render.image_stale": "Используется сцена, сохранённая до последующих изменений.",
"render.image_start": "Начать рендеринг",
"render.image_overwrite": "Заменить существующий файл",
"render.image_export": "Экспорт PNG",
"cmd.render_preflight": "Проверить запрос рендеринга",
"cmd.render_start": "Рендеринг изображения",
"cmd.render_status": "Ход рендеринга",
"cmd.render_cancel": "Отменить рендеринг",
"cmd.render_export": "Экспортировать изображение рендеринга",
"toolbar.image": "ИЗОБРАЖЕНИЕ",
"appearance.surface_response": "Поведение поверхности",
"appearance.pbr_opaque": "Непрозрачный PBR",
"appearance.metallic": "Металличность",
"appearance.roughness": "Шероховатость",
"appearance.dielectric_ior": "Показатель преломления диэлектрика",
"appearance.pbr_help": "Просмотр поверхности во вкладке Рендер или на PNG изделия. Без преломления и пропускания света.",
"render.opaque_preview_help": "Непрозрачный PBR и простые цвета; прозрачность через смешивание. Преломление, HDRI и финальный рендер пока недоступны.",
"render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание",
"joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.",
"joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.",
"joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте."
}
File diff suppressed because it is too large Load Diff
+367 -1
View File
@@ -4,6 +4,12 @@
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 点击下方按钮打开 GitHub 草稿。标题和报告将自动填入。",
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 附上截图:复制并粘贴,或保存为 PNG 后上传。",
"3. Click Submit new issue on GitHub to send your report.": "3. 在 GitHub 上点击 Submit new issue 发送报告。",
"nav.touchpad.label": "触控板",
"nav.touchpad.help": "按住 Shift 并移动指针可平移;按住 Alt/Option 并移动可旋转。无需按鼠标按钮。垂直滚动可缩放。",
"nav.touchpad.orbit": "旋转(按住 Alt/Option 并移动,无需鼠标按钮)",
"nav.touchpad.pan": "平移(按住 Shift 并移动,无需鼠标按钮)",
"nav.touchpad.zoom": "缩放(垂直滚动或双指捏合)",
"nav.touchpad.scroll": "触控板使用垂直滚动进行缩放。双指平移设置将保留供其他预设使用。",
"browser.body_name": "实体{number}",
"browser.component_name": "组件{number}",
"browser.parameters": "参数 ({count})",
@@ -30,6 +36,7 @@
"cmd.component_capture_position": "捕获位置",
"cmd.component_create": "新建组件",
"cmd.component_delete": "删除组件",
"cmd.component_unlock": "解锁内容",
"cmd.component_from_bodies": "由实体创建组件",
"cmd.component_insert_design": "插入设计",
"cmd.component_links": "列出链接",
@@ -82,6 +89,9 @@
"cmd.doc_recovery_list": "可恢复的设计",
"cmd.document_inspect": "检查设计",
"cmd.document_units": "更改设计单位",
"cmd.document_metadata": "设计属性",
"cmd.document_metadata_set": "更改设计属性",
"cmd.document_metadata_remove": "删除设计属性",
"cmd.edit_checkpoint": "检查点",
"cmd.edit_restore_checkpoint": "恢复检查点",
"cmd.engine_commands": "列出命令",
@@ -95,8 +105,10 @@
"cmd.file_insert_mesh": "插入网格",
"cmd.file_insert_step": "插入 STEP",
"cmd.file_save_mesh": "另存为网格",
"cmd.file_slicers": "已安装的切片软件",
"cmd.inspect_accessibility": "可达性分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_compare": "比较实体",
"cmd.inspect_curvature_comb": "曲率梳分析",
"cmd.inspect_curvature_map": "曲率图分析",
"cmd.inspect_draft": "拔模分析",
@@ -141,6 +153,7 @@
"cmd.parameters_delete": "删除参数",
"cmd.parameters_export": "导出参数",
"cmd.parameters_favorite": "收藏参数",
"cmd.parameters_move": "移动参数",
"cmd.parameters_graph": "参数关系图",
"cmd.parameters_import": "导入参数",
"cmd.parameters_list": "列出参数",
@@ -190,6 +203,8 @@
"cmd.sketch_constraint_fix": "固定/解除固定",
"cmd.sketch_constraint_midpoint": "中点",
"cmd.sketch_construction": "普通/构造",
"cmd.sketch_copy": "复制草图图元",
"cmd.sketch_cut": "剪切草图图元",
"cmd.sketch_delete": "删除草图图元",
"cmd.sketch_dimension_text": "移动尺寸文字",
"cmd.sketch_drag": "拖动草图几何",
@@ -214,6 +229,7 @@
"cmd.sketch_move_point": "拖动草图点",
"cmd.sketch_offset_break": "断开偏移关联",
"cmd.sketch_options": "草图选项板选项",
"cmd.sketch_paste": "粘贴草图图元",
"cmd.sketch_point3d": "三维点",
"cmd.sketch_polygon_circumscribed": "外切多边形",
"cmd.sketch_polygon_edge": "边长多边形",
@@ -283,6 +299,7 @@
"cmd.view_list": "列出命名视图",
"cmd.view_rename": "重命名命名视图",
"cmd.view_save": "新建命名视图",
"dialog.computing": "正在计算… {seconds} 秒",
"dialog.custom_axis": "↖ 自定义轴",
"dialog.edit_title": "编辑 {title}",
"dialog.select": "↖ 选择",
@@ -292,6 +309,31 @@
"dialog.selection_guidance": "选择输入项进行修改。点击已选对象可取消选择;按住 Ctrl/Cmd 点击也可切换选择。检查预览后,再按“确定”。",
"document.save_changes": "保存对 {name} 的更改?",
"document.unsaved_changes": "{name} 有未保存的更改。如果不保存,将丢失这些更改。",
"home.documentation": "文档",
"home.in_this_browser": "在此浏览器中",
"home.missing": "文件不存在",
"home.open": "打开…",
"home.opened_days_ago": "{count} 天前打开",
"home.opened_hours_ago": "{count} 小时前打开",
"home.opened_just_now": "刚刚打开",
"home.opened_minutes_ago": "{count} 分钟前打开",
"home.opening": "正在打开…",
"home.recent": "最近使用",
"home.report_issue": "报告问题",
"home.samples": "示例",
"home.whats_new": "新功能",
"sample.bracket.about": "基于 2 mm 钣金规则的基础法兰、边线法兰和卷边",
"sample.bracket.name": "钣金支架",
"sample.enclosure.about": "带凸缘、螺钉凸台、垫块和卡扣的抽壳长方体",
"sample.enclosure.name": "塑料外壳",
"sample.flange.about": "带有由参数驱动的圆形阵列的轮毂和法兰",
"sample.flange.name": "螺栓法兰",
"sample.hinge.about": "两个组件和一个旋转接头,驱动到 60°",
"sample.hinge.name": "铰链装配体",
"sample.plate.about": "带圆角、孔和镗孔凸台的参数化板",
"sample.plate.name": "示例板",
"sample.vents.about": "按矩形阵列排列的槽:行数、列数和间距均为参数",
"sample.vents.name": "通风板",
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
"settings.msaa_reset": "{setting}: {requested}× 不受此图形设备支持;已重置为 {reset}× (支持: {supported})。",
"settings.msaa_unsupported": "此图形设备不支持",
@@ -309,6 +351,147 @@
"ui.active_component": "活动组件",
"ui.agent_cursor": "代理光标",
"ui.agent_cursor_speed": "代理光标速度",
"ui.agent_chat": "智能体聊天",
"ui.agent_chat_command": "智能体命令",
"ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。",
"ui.agent_chat_side": "智能体聊天位置",
"ui.agent_chat_left": "左侧",
"ui.agent_chat_right": "右侧",
"ui.agent_chat_turn_limit": "每条消息的更改数",
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
"ui.agent_chat_connect": "连接",
"ui.agent_chat_stop": "停止",
"ui.agent_chat_stop_tip": "停止智能体及其启动的所有进程",
"ui.agent_chat_close": "关闭",
"ui.agent_chat_minimize": "最小化为细条",
"ui.agent_chat_dock_left": "停靠在左侧",
"ui.agent_chat_dock_right": "停靠在右侧",
"ui.agent_chat_send": "发送",
"ui.agent_chat_cancel": "取消",
"ui.agent_chat_refresh": "刷新",
"ui.agent_chat_pick": "智能体",
"ui.agent_chat_custom_hint": "任何通过标准输入输出使用 Agent Client Protocol 的程序。不经过 shell 运行。",
"ui.agent_chat_not_installed": "未安装",
"ui.agent_chat_none_found": "未找到编程智能体。请安装以下之一,然后点击刷新:",
"ui.agent_chat_detecting": "正在查找智能体…",
"ui.agent_chat_signed_in": "已登录",
"ui.agent_chat_key_in_env": "已设置密钥",
"ui.agent_chat_not_signed_in": "未登录",
"ui.agent_chat_state_off": "未连接",
"ui.agent_chat_state_starting": "正在启动…",
"ui.agent_chat_state_ready": "就绪",
"ui.agent_chat_state_busy": "工作中…",
"ui.agent_chat_state_auth": "需要登录",
"ui.agent_chat_state_exited": "智能体已停止",
"ui.agent_chat_changes_of": "更改 {n}/{limit}",
"ui.agent_chat_auth_title": "登录智能体",
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
"ui.agent_chat_auth_none": "该智能体未在此提供登录方式:请按照它自己的文档登录。",
"ui.agent_chat_auth_terminal": "在终端中运行以下命令,然后再次点击连接:",
"ui.agent_chat_auth_agent": "使用 {name} 登录",
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
"ui.agent_chat_auth_retry": "我已登录:重新连接",
"ui.agent_chat_copy": "复制",
"ui.agent_chat_permission": "智能体请求运行:{title}",
"ui.agent_chat_answered": "回答:{option}",
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
"ui.agent_chat_allow": "允许",
"ui.agent_chat_deny": "拒绝",
"ui.agent_chat_allowed": "已允许",
"ui.agent_chat_denied": "已拒绝",
"ui.agent_chat_changes": "{n} 项更改,一步撤销",
"ui.agent_chat_undo_changes": "撤销这些更改",
"ui.agent_chat_undone": "(已撤销)",
"ui.agent_chat_undo_label": "智能体聊天:{message}",
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
"ui.agent_chat_refused": "不允许智能体执行:{reason}",
"ui.agent_chat_wait_turn": "智能体仍在处理上一条消息:请等待或点击取消。",
"ui.agent_chat_placeholder": "让智能体修改设计…",
"ui.agent_chat_attach_view": "附加视图",
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
"ui.agent_chat_no_images": "该智能体不接收图片",
"ui.agent_chat_view_attached": "已附加视图",
"ui.agent_chat_no_cli": "在应用旁边未找到 solidcraft-cli,因此智能体无法访问设计。",
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
"ui.agent_chat_exited": "智能体已停止。",
"ui.agent_chat_failed_start": "智能体无法启动:{error}",
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
"ui.agent_chat_none_found_short": "未找到智能体",
"ui.ai_add": "添加",
"ui.ai_add_agent": "添加智能体…",
"ui.ai_add_agent_title": "添加智能体",
"ui.ai_add_api": "OpenAI 兼容 API",
"ui.ai_add_custom": "自定义命令",
"ui.ai_added": "已添加",
"ui.ai_add_installed": "已安装的智能体",
"ui.ai_add_installed_note": "为已安装的智能体添加一个自己的条目,例如使用其他参数。已安装的智能体会自动列出。",
"ui.ai_agent_mode_tip": "智能体自己的模式:它自己的工具(shell、文件编辑)遵循此模式。SolidCraft 的工具遵循“首选项 > AI”中的权限。",
"ui.ai_agents": "智能体",
"ui.ai_always_allow": "始终允许",
"ui.ai_always_allowed": "始终允许的项目",
"ui.ai_always_allowed_none": "尚无始终允许的项目。你在聊天中选择“始终允许”的回答会列在这里。",
"ui.ai_always_allow_tip": "从现在起允许该智能体使用 {tool},不再询问",
"ui.ai_auto_allowed": "已自动允许:{title}({why})",
"ui.ai_auto_mode": "你的权限设置",
"ui.ai_auto_ours": "SolidCraft 会自行检查它自己的工具",
"ui.ai_auto_remembered": "你选择了始终允许",
"ui.ai_bad_url": "请输入以 http:// 或 https:// 开头的 URL",
"ui.ai_base_url": "基础 URL",
"ui.ai_button": "AI",
"ui.ai_copy_suffix": "副本",
"ui.ai_custom_url": "自定义 URL",
"ui.ai_default": "默认",
"ui.ai_http_busy": "端点繁忙或已达上限,请稍后再试",
"ui.ai_http_key": "端点拒绝了密钥:请在“首选项 > AI”中检查",
"ui.ai_http_not_found": "找不到该端点或模型:请检查基础 URL 和模型名称",
"ui.ai_key": "API 密钥",
"ui.ai_keychain_error": "系统钥匙串拒绝:{error}",
"ui.ai_key_hint": "可选;保存在系统钥匙串中",
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。",
"ui.ai_mode_all": "全部允许",
"ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。",
"ui.ai_mode_all_warning": "智能体可以不经询问保存、覆盖、导出和替换你的设计。只在你信任的智能体和已保存的设计上使用。",
"ui.ai_mode_ask": "每次询问",
"ui.ai_mode_ask_note": "对设计的每项更改和每个文件操作都要等待你的允许。",
"ui.ai_mode_edits": "自动允许模型编辑",
"ui.ai_mode_edits_note": "建模命令无需询问即可运行;保存、打开、导出等文件操作或破坏性操作仍会询问。",
"ui.ai_mode_global": "按上方设置",
"ui.ai_model": "模型",
"ui.ai_modes_scope": "这些设置适用于 SolidCraft 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。",
"ui.ai_name": "名称",
"ui.ai_need_command": "请输入启动智能体的命令",
"ui.ai_need_model": "请输入模型名称",
"ui.ai_no_answer": "智能体未及时响应",
"ui.ai_no_http": "此版本无法访问 API 端点",
"ui.ai_no_keychain": "系统钥匙串不可用,无法保存密钥。无需密钥的本地服务器仍可使用。",
"ui.ai_panel": "面板",
"ui.ai_permissions": "权限",
"ui.ai_preset": "预设",
"ui.ai_remove": "移除",
"ui.ai_request_limit": "每条消息 24 次请求后已停止;再发送一条消息即可继续",
"ui.ai_reset": "重置",
"ui.ai_settings": "AI 设置…",
"ui.ai_show_features": "显示 AI 功能",
"ui.ai_show_features_note": "智能体聊天:一个面板,由你已在使用的编程智能体或你选择的模型端点处理打开的设计。关闭后:没有 AI 按钮、快捷键、菜单项或面板,也不会查找或启动任何程序。",
"ui.ai_switch_tip": "更改此对话的智能体",
"ui.ai_test": "测试",
"ui.ai_test_acp_ok": "已响应:{agent}",
"ui.ai_test_acp_tip": "短暂启动智能体并请求一个会话,然后结束它",
"ui.ai_test_api_tip": "向端点查询其模型列表",
"ui.ai_testing": "正在测试…",
"ui.ai_test_no_model": "已连接:{count} 个模型,但没有 {model}",
"ui.ai_test_ok": "已连接:{count} 个模型",
"ui.ai_toolbar_button": "在工具栏显示按钮",
"ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。",
"ui.ai_tool_done": "完成",
"ui.ai_tool_failed": "失败",
"ui.ai_tool_running": "运行中",
"ui.ai_tool_unfold": "点击显示返回内容",
"ui.ai_tool_waiting": "等待中",
"ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销",
"AI": "AI",
"ui.all": "全部",
"ui.angle": "角度",
"ui.angle_decimals": "角度小数位",
@@ -316,6 +499,16 @@
"ui.animation": "动画",
"ui.animation_speed": "动画速度",
"ui.anti_aliasing": "抗锯齿",
"ui.supersampling": "超级采样 (SSAA)",
"ui.supersampling_off": "关 (1×)",
"ui.supersampling_adaptive": "仅在视图静止时",
"settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。",
"ui.display_quality": "显示质量",
"ui.display_quality_coarse": "粗糙",
"ui.display_quality_normal": "标准",
"ui.display_quality_fine": "精细",
"ui.display_quality_ultra": "极高",
"settings.display_quality_note": "级别越精细,曲面和边显示越平滑,但网格生成耗时更长。只影响视口,不影响设计或导出。",
"ui.apply": "应用",
"ui.arc": "圆弧",
"ui.assemble": "装配",
@@ -345,10 +538,47 @@
"ui.click_selects_whole_bodies": "单击选择整个实体",
"ui.click_then_press_key": "单击,然后按新按键",
"ui.close": "关闭",
"ui.material_review.title": "检查物理材料数据",
"ui.material_review.scope": "仅检查候选数据:不指定或保存。数值验证不等于工程认证。",
"ui.material_review.input": "粘贴架构版本1的材料卡(JSON)",
"ui.material_review.review": "检查材料卡",
"ui.material_review.limit": "材料卡超过256 KiB输入限制。",
"ui.material_review.unknown": "未知",
"ui.material_review.properties": "属性",
"ui.material_review.conditions": "声明的条件",
"ui.material_review.source": "来源",
"ui.material_review.axes": "材料局部坐标轴;不指定实体方向。",
"ui.material_review.derived": "推导的各向同性模量",
"ui.material_review.claims": "来源和许可证声明未经核实。缺失值保持未知。",
"ui.material_review.density": "密度",
"ui.material_review.elasticity": "弹性",
"ui.material_review.tensile_strength": "抗拉强度",
"ui.material_review.compressive_strength": "抗压强度",
"ui.material_review.yield_strength": "屈服强度",
"ui.material_review.shear_strength": "抗剪强度",
"ui.material_review.thermal_conductivity": "导热系数",
"ui.material_review.thermal_expansion": "热膨胀",
"ui.material_review.specific_heat": "比热容",
"ui.material_review.moisture_unit": "kg 水 / kg 干燥材料",
"ui.coil": "螺旋",
"ui.coincident": "重合",
"ui.collapse": "折叠",
"ui.collinear": "共线",
"ui.colour_vision": "色觉",
"ui.colour_vision_deutan": "绿色觉异常(Deutan)",
"ui.colour_vision_deutan_tip": "绿色感知较弱或缺失时使用",
"ui.colour_vision_failed": "失败",
"ui.colour_vision_fixed": "固定",
"ui.colour_vision_mode": "模式",
"ui.colour_vision_note": "更改表示含义的颜色:草图状态、轴、折弯、分析和图标。如果使用 Windows 颜色滤镜,请保持“关闭”。",
"ui.colour_vision_off": "关闭",
"ui.colour_vision_off_tip": "主题本身的颜色",
"ui.colour_vision_projected": "投影",
"ui.colour_vision_protan": "红色觉异常(Protan)",
"ui.colour_vision_protan_tip": "红色感知较弱或缺失时使用",
"ui.colour_vision_sample": "示例",
"ui.colour_vision_tritan": "蓝色觉异常(Tritan)",
"ui.colour_vision_tritan_tip": "蓝色感知较弱或缺失时使用",
"ui.combine": "组合",
"ui.coming_later": "即将推出",
"ui.concentric": "同心",
@@ -559,6 +789,10 @@
"ui.quit": "退出",
"ui.radial_context_menu": "环形上下文菜单",
"ui.radius": "半径",
"ui.pull_direction": "拔模方向",
"ui.tool_direction": "刀具方向",
"ui.reachable": "可到达",
"ui.blocked": "被遮挡",
"ui.radius_type": "半径类型",
"ui.rectangle": "矩形",
"ui.rectangular_pattern": "矩形阵列",
@@ -573,9 +807,33 @@
"ui.revolve": "旋转",
"ui.right": "右视",
"ui.save": "保存",
"ui.heading_save_mesh": "另存为网格",
"ui.format": "格式",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "带面标签的 3MF",
"ui.send_to_slicer": "发送到切片软件",
"ui.slicer": "切片软件",
"ui.custom_program": "自定义…",
"ui.no_slicer_found": "未找到切片软件",
"ui.send": "发送",
"ui.custom_program_tip": "选择要添加到列表的切片软件程序",
"ui.slicer_delete_tip": "{program}\n按 Delete 键从列表中移除",
"ui.save_mesh_pick_bodies": "请选择要保存的实体",
"ui.save_mesh_no_slicer": "未找到切片软件:请选择“自定义…”并指定切片软件程序,或关闭“发送到切片软件”",
"ui.not_a_program": "{path} 不是可以启动的程序",
"ui.sent_to_slicer": "已发送到 {slicer}",
"ui.saved_to": "已保存:{path}",
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
"ui.save_as": "另存为",
"ui.save_as_ellipsis": "另存为...",
"ui.scale": "缩放",
"ui.orientation": "定向",
"ui.twist": "扭转",
"ui.end_scale": "末端缩放",
"ui.sweep_turn_with_path": "随路径转动",
"ui.sweep_keep_as_drawn": "保持绘制方向",
"ui.sweep_follow_direction": "跟随方向",
"ui.section_analysis": "截面分析",
"ui.select": "选择",
"ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
@@ -727,6 +985,8 @@
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "许多 CAD 程序的文件以 Y 为上轴,因此在此处打开时会平躺(Z 朝上)。",
"Filter": "筛选",
"Find in Browser": "在浏览器中查找",
"Copy Error Message": "复制错误消息",
"Copy Warning Message": "复制警告消息",
"Flat size": "平板尺寸",
"Follow camera (Look At sketches)": "跟随相机(注视草图)",
"Font": "字体",
@@ -915,6 +1175,17 @@
"ui.pinToShortcuts": "固定到快捷方式",
"ui.pinToToolbar": "固定到工具栏",
"ui.properties": "属性",
"ui.design_properties.project": "项目",
"ui.design_properties.part_number": "零件号",
"ui.design_properties.revision": "版本",
"ui.design_properties.author": "作者",
"ui.design_properties.organization": "组织",
"ui.design_properties.description": "描述",
"ui.design_properties.license": "许可证",
"ui.design_properties.not_text": "已保存为非文本值;在此输入将替换它。",
"ui.design_properties.exports": "导出 STEP、IGES 和 3MF 时,若格式有对应字段,会写入这些属性。",
"ui.design_properties.others": "其他工具的数据",
"ui.design_properties.others_note": "按原样保存在设计中,从不导出。",
"ui.removeSection": "移除剖面",
"ui.removeFromToolbar": "从工具栏移除",
"ui.repeat": "重复",
@@ -938,6 +1209,9 @@
"ui.updateToCurrentView": "更新为当前视图",
"ui.zoom": "缩放",
"ui.command_done": "{command} 完成",
"drive.stopped_by_loop": "停在 {value}:经过 {joint} 的环路无法再闭合",
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
"drive.unconnected": "没有任何移动:{part} 未连接到任何固定的零部件",
"timeline.group": "新建特征文件夹",
"timeline.ungroup": "移除特征文件夹",
"timeline.rename_group": "重命名特征文件夹",
@@ -980,6 +1254,18 @@
"cmd.material_properties_directional": "方向弹性模量",
"update.button": "更新",
"update.button_tip": "SolidCraft {version} 已发布",
"install.menu": "安装应用…",
"install.button_tip": "将 SolidCraft 安装为应用:在独立窗口中打开,离线也能使用",
"install.title": "安装 SolidCraft",
"install.steps_intro": "Safari 通过自身的菜单安装网页应用:",
"install.home_screen_1": "轻点“共享”(若不在工具栏上,则在 … 菜单中)。",
"install.home_screen_2": "选择“添加到主屏幕”。",
"install.home_screen_3": "轻点“添加”。",
"install.home_screen_after": "之后 SolidCraft 从主屏幕上的图标打开,不显示 Safari 的工具栏,离线也能使用。",
"install.dock_1": "选择“文件”›“添加到程序坞”。",
"install.dock_2": "点按“添加”。",
"install.dock_after": "之后 SolidCraft 从程序坞在独立窗口中打开,离线也能使用。",
"install.done": "完成",
"update.title": "有可用更新",
"update.available": "SolidCraft {version} 已发布",
"update.you_have": "当前版本为 {version}。",
@@ -1011,5 +1297,85 @@
"update.restart_later": "稍后",
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。"
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。",
"cmd.render_settings_get": "渲染设置",
"cmd.render_settings_set": "保存渲染设置",
"cmd.render_scene": "检查渲染场景",
"cmd.render_settings_apply": "应用渲染相机",
"toolbar.render": "渲染",
"toolbar.render_scene": "场景",
"render.preview": "集成预览",
"render.preview_help": "光栅着色和混合透明度。暂不支持折射、HDRI 和最终渲染。",
"render.setup_name": "设置名称",
"render.background": "场景背景",
"render.perspective": "透视相机",
"render.fit": "适配产品视图",
"render.restore_camera": "恢复已保存相机",
"render.save_setup": "保存设置",
"render.unsaved": "未保存的预览更改",
"render.reload_settings": "重新加载已保存设置",
"render.unavailable_preview": "此设置请求了不可用的预览配置。",
"render.use_preview": "使用集成预览",
"render.navigation_help": "相机的操作与设计中相同(首选项 › 导航)。保存设置会记录当前相机。",
"render.return_design": "返回设计",
"render.image_title": "渲染图像",
"render.image_help": "CPU 预览图像 · PNG · 近似透明度",
"render.image_width": "宽度",
"render.image_height": "高度",
"render.image_transparent": "透明背景",
"render.image_check": "检查渲染请求",
"render.image_supported": "支持此渲染请求;开始时将再次检查队列和内存池。",
"render.image_queued": "等待渲染",
"render.image_preparing": "正在准备图像",
"render.image_running": "正在渲染图像",
"render.image_encoding": "正在编码 PNG",
"render.image_cancelling": "正在取消",
"render.image_cancelled": "渲染已取消",
"render.image_completed": "图像可导出",
"render.image_failed": "渲染失败",
"render.image_unknown": "渲染状态不可用",
"render.image_reduce": "请尝试更小的图像或更简单的场景。",
"render.image_too_large": "超出图像限制:每边 4096 像素,总计 4,194,304 像素。",
"render.image_use_largest": "使用此宽高比下的最大尺寸:",
"render.image_cancel": "取消渲染",
"render.image_stale": "此图像使用后续更改之前捕获的场景。",
"render.image_start": "开始渲染",
"render.image_overwrite": "替换现有文件",
"render.image_export": "导出 PNG",
"cmd.render_preflight": "检查渲染请求",
"cmd.render_start": "渲染图像",
"cmd.render_status": "渲染进度",
"cmd.render_cancel": "取消渲染",
"cmd.render_export": "导出 PNG",
"toolbar.image": "图像",
"appearance.surface_response": "表面响应",
"appearance.pbr_opaque": "不透明 PBR",
"appearance.metallic": "金属度",
"appearance.roughness": "粗糙度",
"appearance.dielectric_ior": "电介质折射率",
"appearance.pbr_help": "在渲染工作区或产品 PNG 中查看此表面。不支持折射或透射。",
"render.opaque_preview_help": "不透明 PBR 和纯色;透明效果使用混合。不支持折射、HDRI 和最终渲染。",
"render.opaque_image_summary": "预览 PNG · 不透明 PBR · 混合透明",
"UNTRIM": "取消修剪",
"Face": "面",
"Holes": "孔",
"Holes (edges on them)": "孔(孔上的边)",
"In place (surfaces)": "原位(曲面)",
"None": "无",
"Picked": "所选",
"Restore outer boundary": "恢复外边界",
"RULED SURFACE": "直纹曲面",
"Mitre": "斜接",
"Reference face": "参考面",
"Round": "圆角",
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。",
"Circumference": "周长",
"Arc length": "弧长",
"Slot width": "槽宽",
"Centre to centre": "中心距",
"Overall length": "总长",
"Min": "最小",
"Max": "最大"
}
+24 -7
View File
@@ -10,6 +10,10 @@ struct U {
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
acc_v: vec4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
// blocked; curvature flat, middle, tight
ana_c: array<vec4<f32>, 8>,
ana_f: vec4<f32>, // x = 1: stripe the negative draft and blocked faces
};
@group(1) @binding(2) var acc_t: texture_2d<f32>;
@@ -150,8 +154,8 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
// Slack of a pixel and a half, more on slopes (the map holds the pixel's highest point).
let slack = u.ana.y * (1.0 + sqrt(max(1.0 - cn * cn, 0.0)) / max(cn, 0.05));
let clear = dot(i.wp, d) >= top - slack;
var c = vec3<f32>(0.85, 0.22, 0.20);
if (facing && clear) { c = vec3<f32>(0.20, 0.75, 0.30); }
var c = u.ana_c[4].rgb;
if (facing && clear) { c = u.ana_c[3].rgb; } else { c = stripe(i, c); }
return out_color(vec4<f32>(c * lit, 1.0));
}
if (u.ana.x > 3.5) {
@@ -171,24 +175,37 @@ fn analysis(i: TOut, k: f32) -> vec4<f32> {
}
if (u.ana.x < 2.5) {
let a = degrees(asin(clamp(dot(normalize(i.n), normalize(u.pull.xyz)), -1.0, 1.0)));
var c = vec3<f32>(0.95, 0.80, 0.15);
if (a >= u.ana.y) { c = vec3<f32>(0.20, 0.75, 0.30); }
if (a <= -u.ana.y) { c = vec3<f32>(0.85, 0.22, 0.20); }
var c = u.ana_c[1].rgb;
if (a >= u.ana.y) { c = u.ana_c[0].rgb; }
if (a <= -u.ana.y) { c = stripe(i, u.ana_c[2].rgb); }
return out_color(vec4<f32>(c * lit, 1.0));
}
// Curvature from how fast the normal turns across the pixel.
let t = clamp(k * u.ana.y, 0.0, 1.0);
let c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
let c = select(mix(u.ana_c[6].rgb, u.ana_c[7].rgb, t * 2.0 - 1.0), mix(u.ana_c[5].rgb, u.ana_c[6].rgb, t * 2.0), t < 0.5);
return out_color(vec4<f32>(c * lit, 1.0));
}
/// A "bad" analysis face (negative draft, blocked): with the stripes on (colour vision modes), the
/// section cap's 45 degree hatch over it, so colour is not the only cue.
fn stripe(i: TOut, c: vec3<f32>) -> vec3<f32> {
if (u.ana_f.x < 0.5) { return c; }
let h = fract((i.pos.x + i.pos.y) / 9.0);
return select(c, c * 0.6, h < 0.22);
}
@fragment
fn fs_tri(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return shade(i);
}
/// Reference planes: shaded like `fs_tri`, but never cut by the section plane.
@fragment
fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
return shade(i);
}
fn shade(i: TOut) -> vec4<f32> {
// A zero normal marks flat colour (selection fills, translucent planes).
if (length(i.n) < 0.5) {
+97 -2
View File
@@ -1,5 +1,5 @@
//! Original light and dark palette values. Compiled by the UI theme boundary.
use crate::theme::Tokens;
use crate::theme::{AnalysisColors, IconColors, Tokens};
use egui::Color32;
impl Tokens {
pub const fn light() -> Tokens {
@@ -76,13 +76,54 @@ impl Tokens {
rubber_band: Color32::from_rgb(30, 120, 230),
app_bar_text: Color32::from_rgb(230, 234, 242),
app_bar_hover: Color32::from_rgba_unmultiplied_const(255, 255, 255, 28),
app_bar_tab_edge: Color32::from_rgba_unmultiplied_const(255, 255, 255, 40),
timeline_item: Color32::WHITE,
timeline_item_error: Color32::from_rgb(250, 220, 220),
timeline_marker: Color32::from_rgb(70, 76, 88),
field: Color32::WHITE,
box_window: Color32::from_rgb(38, 120, 218),
box_crossing: Color32::from_rgb(40, 160, 80),
// Equal to `view::colors` (the engine's tags), so mapping a tag to its token
// changes nothing.
sketch_projected: Color32::from_rgb(150, 60, 190),
sketch_lost: Color32::from_rgb(200, 60, 90),
sketch_construction: Color32::from_rgb(230, 130, 40),
sketch_centerline: Color32::from_rgb(200, 110, 60),
triad: [Color32::from_rgb(220, 60, 60), Color32::from_rgb(60, 170, 60), Color32::from_rgb(60, 100, 230)],
gizmo: [Color32::from_rgb(225, 70, 60), Color32::from_rgb(80, 190, 80), Color32::from_rgb(70, 130, 240)],
bend_up: Color32::from_rgb(30, 140, 50),
bend_down: Color32::from_rgb(200, 90, 20),
interference: Color32::from_rgb(235, 45, 40),
analysis: AnalysisColors {
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]],
hatch: false,
},
icons: IconColors {
sheet: Color32::from_rgb(170, 200, 240),
sheet_2: Color32::from_rgb(150, 185, 235),
sheet_light: Color32::from_rgb(214, 229, 248),
sheet_line: Color32::from_rgb(127, 166, 218),
highlight: Color32::WHITE,
glint: Color32::from_rgba_premultiplied(160, 160, 160, 160),
folder: Color32::from_rgb(236, 200, 110),
plane: Color32::from_rgb(244, 214, 150),
fold: Color32::from_rgb(130, 140, 160),
stripe: Color32::from_gray(30),
sky: Color32::from_rgb(190, 205, 230),
floor: Color32::from_rgb(60, 62, 70),
paint: [Color32::from_rgb(220, 70, 60), Color32::from_rgb(70, 150, 230), Color32::from_rgb(240, 200, 60)],
axes: [Color32::from_rgb(210, 60, 60), Color32::from_rgb(60, 160, 60), Color32::from_rgb(60, 100, 220)],
access: [Color32::from_rgb(70, 180, 90), Color32::from_rgb(220, 70, 60)],
draft: [Color32::from_rgb(70, 180, 90), Color32::from_rgb(235, 200, 50)],
curvature: [Color32::from_rgb(60, 110, 230), Color32::from_rgb(70, 190, 90), Color32::from_rgb(225, 60, 45)],
interference: Color32::from_rgb(225, 70, 60),
warning: Color32::from_rgb(250, 200, 60),
agent: Color32::from_rgb(168, 85, 247),
marker_halo: Color32::from_black_alpha(150),
},
app_bar_icon: [Color32::from_rgb(225, 230, 238), Color32::from_rgb(120, 140, 170), Color32::from_rgb(120, 190, 255)],
logo: [Color32::from_rgb(96, 158, 236), Color32::from_rgb(58, 136, 238), Color32::from_rgb(55, 108, 197), Color32::WHITE],
}
}
pub const fn dark() -> Tokens {
@@ -135,6 +176,60 @@ impl Tokens {
t.field = Color32::from_rgb(30, 34, 41);
t.box_window = Color32::from_rgb(70, 150, 255);
t.box_crossing = Color32::from_rgb(80, 200, 120);
t.bend_up = Color32::from_rgb(110, 200, 120);
t.bend_down = Color32::from_rgb(240, 150, 80);
t
}
/// Colour vision, red-green (Protan and Deutan share it, #778): values of the theme's colours
/// that a weak red or green sense still tells apart. Each was measured against normal vision,
/// protan and deutan, with two simulations of each loss (`colour_vision_tests`).
pub(super) const fn red_green(self, dark: bool) -> Tokens {
let mut t = self;
let positive = [0.20, 0.45, 0.90];
let negative = [0.85, 0.40, 0.05];
t.triad[0] = Color32::from_rgb(200, 40, 40);
t.triad[1] = Color32::from_rgb(120, 210, 80);
t.gizmo[1] = Color32::from_rgb(140, 220, 90);
t.preview_sketch = Color32::from_rgb(80, 185, 145);
t.analysis.draft = [positive, [0.95, 0.90, 0.60], negative];
t.analysis.access = [positive, negative];
t.analysis.hatch = true;
// The icons that show the same meanings follow; on the dark toolbar the blue is a little
// stronger, to stay clear of the icon fill.
let good = if dark { Color32::from_rgb(35, 115, 245) } else { Color32::from_rgb(51, 115, 230) };
t.icons.axes[0] = t.triad[0];
t.icons.axes[1] = t.triad[1];
t.icons.access = [good, Color32::from_rgb(217, 102, 13)];
t.icons.draft = [good, Color32::from_rgb(242, 230, 153)];
if dark {
t.bend_up = Color32::from_rgb(110, 165, 255);
t.bend_down = Color32::from_rgb(240, 180, 70);
t.sketch_accent = Color32::from_rgb(150, 225, 195);
} else {
t.sketch_projected = Color32::from_rgb(205, 120, 215);
t.sketch_locked = Color32::from_rgb(60, 150, 120);
t.timeline_item_error = Color32::from_rgb(232, 196, 170);
t.bend_up = Color32::from_rgb(30, 100, 210);
t.bend_down = Color32::from_rgb(200, 120, 0);
t.sketch_accent = Color32::from_rgb(60, 150, 105);
}
t
}
/// Colour vision, blue-yellow (Tritan, #778), measured against normal vision and tritan.
pub(super) const fn blue_yellow(self, dark: bool) -> Tokens {
let mut t = self;
t.analysis.hatch = true;
t.preview_sketch = Color32::from_rgb(125, 180, 105);
if dark {
t.sketch_locked = Color32::from_rgb(140, 225, 110);
t.box_crossing = Color32::from_rgb(240, 110, 170);
t.glyph_text = Color32::from_rgb(240, 180, 165);
} else {
t.sketch_locked = Color32::from_rgb(20, 165, 70);
t.box_crossing = Color32::from_rgb(200, 60, 120);
}
t
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "solidcraft-derive"
description = "SolidCraft derive macros: the walk over a feature's geometry, from role attributes on its fields"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
proc-macro = true
[lints]
workspace = true
[dependencies]
proc-macro2 = { workspace = true }
quote = { workspace = true }
syn = { workspace = true }
+294
View File
@@ -0,0 +1,294 @@
//! `#[derive(Walk)]`: the walks over a feature (`crate::walk::Walk`), generated from the fields of
//! a struct or an enum's variants. Each walk visits the fields in declaration order, and comes in
//! `&` and `&mut`:
//!
//! - expressions (`walk_exprs`): each with its label, kind and role; the inputs' order names their
//! parameters (`dN`);
//! - references (`walk_refs`): sketches by id, features and bodies by name;
//! - geometry (`walk_geometry`): points with their roles, planes, construction references,
//! placement points and profile selections.
//!
//! What a field is, is written once, on the field, with `#[walk(...)]`:
//!
//! - no attribute: the field's own type walks itself (`impl Walk`): a plane, a nested struct, a
//! list of them, or nothing for names, flags and numbers. A bare `Vec3` has no walk, so a point
//! that does not say its role does not compile;
//! - `length = label`, `angle = label`, `unitless = label`: an expression input (a `String`, an
//! optional one, a list or an array of them, or `(f64, String)` pairs). The label is any
//! expression: a `&'static str`, an array of them (one per element), or a closure
//! `|i: usize, n: usize| -> &'static str`; it may read the other fields;
//! - `sketch`, `feature`, `body`: a reference (an id, or a name, optional or a list);
//! - `pick`, `pinned`, `pos`, `dir`: a point (`Vec3`, optional or a list) with that role;
//! `pick_if = cond`: a pick when `cond` holds, else a position;
//! - `placement`: a primitive's placement point (its X, Y and Z expressions are not inputs);
//! - `profiles_of = sketch_field`, optionally with `when = cond`: a profile selection;
//! - `after = field`: visit this field right after `field` instead of in declaration order (where
//! the parameter names already given need it);
//! - `picks_faces`, on a nested value: its positions pick faces here (a `FaceAt` that names the
//! face it is on).
//!
//! Conditions and labels are worked out before the walk starts. The generated code names
//! `crate::walk`, so the derive is for `solidcraft-doc`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use proc_macro::TokenStream;
use proc_macro2::{Span, TokenStream as Tokens};
use quote::{format_ident, quote};
use syn::{Data, DeriveInput, Expr, Fields, Ident, Member, parse_macro_input};
#[proc_macro_derive(Walk, attributes(walk))]
pub fn derive_walk(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match expand(&input) {
Ok(t) => t.into(),
Err(e) => e.to_compile_error().into(),
}
}
/// A field's role in the geometry walk.
enum Geo {
Points(&'static str),
PickIf(Expr),
Placement,
Profiles { sketch: Ident, when: Option<Expr> },
}
/// What a field is, from its `#[walk(...)]` attributes.
#[derive(Default)]
struct Roles {
geo: Option<Geo>,
/// The kind (`Length`, `Angle`, `Unitless`) and the label.
expr: Option<(&'static str, Expr)>,
/// `Sketch`, `Feature` or `Body`.
reference: Option<&'static str>,
after: Option<Ident>,
/// The positions inside this (nested) value pick faces here.
picks_faces: bool,
}
impl Roles {
/// No role given: the field's type walks itself.
fn nested(&self) -> bool {
self.geo.is_none() && self.expr.is_none() && self.reference.is_none()
}
}
fn roles(field: &syn::Field) -> syn::Result<Roles> {
let mut r = Roles::default();
for attr in field.attrs.iter().filter(|a| a.path().is_ident("walk")) {
let mut sketch = None;
let mut when = None;
attr.parse_nested_meta(|meta| {
let name = meta.path.get_ident().map(Ident::to_string).unwrap_or_default();
match name.as_str() {
"pick" => r.geo = Some(Geo::Points("Pick")),
"pinned" => r.geo = Some(Geo::Points("Pinned")),
"pos" => r.geo = Some(Geo::Points("Pos")),
"dir" => r.geo = Some(Geo::Points("Dir")),
"placement" => r.geo = Some(Geo::Placement),
"pick_if" => r.geo = Some(Geo::PickIf(meta.value()?.parse()?)),
"profiles_of" => sketch = Some(meta.value()?.parse::<Ident>()?),
"when" => when = Some(meta.value()?.parse::<Expr>()?),
"length" => r.expr = Some(("Length", meta.value()?.parse()?)),
"angle" => r.expr = Some(("Angle", meta.value()?.parse()?)),
"unitless" => r.expr = Some(("Unitless", meta.value()?.parse()?)),
"sketch" => r.reference = Some("Sketch"),
"feature" => r.reference = Some("Feature"),
"body" => r.reference = Some("Body"),
"after" => r.after = Some(meta.value()?.parse()?),
"picks_faces" => r.picks_faces = true,
_ => return Err(meta.error("unknown walk role")),
}
Ok(())
})?;
if let Some(sketch) = sketch {
r.geo = Some(Geo::Profiles { sketch, when });
} else if when.is_some() {
return Err(syn::Error::new_spanned(attr, "`when` goes with `profiles_of`"));
}
}
Ok(r)
}
#[derive(Clone, Copy, PartialEq)]
enum Which {
Exprs,
Refs,
Geometry,
}
/// A value worked out before the walk (a condition, a label, a sketch id) for field `b`.
fn pre(b: &Ident, what: &str) -> Ident {
format_ident!("__{}_{}", what, b)
}
/// The statements that walk field `b` in walk `which`.
fn walk_field(b: &Ident, r: &Roles, which: Which, mutable: bool) -> Tokens {
let w = quote!(crate::walk);
let each = if mutable { quote!(each_mut) } else { quote!(each) };
if r.nested() {
let m = match which {
Which::Exprs => "walk_exprs",
Which::Refs => "walk_refs",
Which::Geometry => "walk_geometry",
};
let m = if mutable { format_ident!("{}_mut", m) } else { format_ident!("{}", m) };
if r.picks_faces && which == Which::Geometry {
return quote! {
#w::Walk::#m(#b, &mut |g| f(match g { #w::Geo::Pos(p) => #w::Geo::Pick(p), g => g }));
};
}
return quote! { #w::Walk::#m(#b, f); };
}
match which {
Which::Exprs => {
let label = pre(b, "label");
match (&r.expr, &r.geo) {
(Some((kind, _)), _) => {
let kind = Ident::new(kind, Span::call_site());
quote! {
#w::ExprField::#each(#b, &mut |i, n, e| f((#w::Label::at(&#label, i, n), e, #w::Kind::#kind, #w::ExprRole::Input)));
}
}
(None, Some(Geo::Placement)) => quote! {
#w::ExprField::#each(#b, &mut |i, n, e| f((#w::Label::at(&["X", "Y", "Z"], i, n), e, #w::Kind::Length, #w::ExprRole::Placement)));
},
_ => quote!(),
}
}
Which::Refs => match r.reference {
Some(kind) => {
let t = if kind == "Sketch" { quote!(u64) } else { quote!(String) };
let kind = Ident::new(kind, Span::call_site());
quote! { #w::Each::<#t>::#each(#b, &mut |x| f(#w::Ref::#kind(x))); }
}
None => quote!(),
},
Which::Geometry => match &r.geo {
Some(Geo::Points(v)) => {
let v = Ident::new(v, Span::call_site());
quote! { #w::Each::<#w::Vec3>::#each(#b, &mut |p| f(#w::Geo::#v(p))); }
}
Some(Geo::PickIf(_)) => {
let pick = pre(b, "pick");
quote! {
#w::Each::<#w::Vec3>::#each(#b, &mut |p| f(if #pick { #w::Geo::Pick(p) } else { #w::Geo::Pos(p) }));
}
}
Some(Geo::Placement) => quote! { f(#w::Geo::Placement(#b)); },
Some(Geo::Profiles { .. }) => {
let (id, when) = (pre(b, "sketch"), pre(b, "when"));
quote! {
if #when && let Some(id) = #id {
#w::Each::<#w::ProfileSel>::#each(#b, &mut |sel| f(#w::Geo::Profiles(id, sel)));
}
}
}
None => quote!(),
},
}
}
/// The values field `b`'s walk needs, worked out before the walk starts (the walk may hand out
/// the fields they read).
fn pre_values(b: &Ident, r: &Roles, which: Which) -> Tokens {
let w = quote!(crate::walk);
match (which, &r.expr, &r.geo) {
(Which::Exprs, Some((_, label)), _) => {
let l = pre(b, "label");
quote! { let #l = #label; }
}
(Which::Geometry, _, Some(Geo::PickIf(cond))) => {
let p = pre(b, "pick");
quote! { let #p: bool = #cond; }
}
(Which::Geometry, _, Some(Geo::Profiles { sketch, when })) => {
let (id, wh) = (pre(b, "sketch"), pre(b, "when"));
let when = when.as_ref().map_or(quote!(true), |c| quote!(#c));
quote! {
let #wh: bool = #when;
let mut #id: Option<u64> = None;
#w::Each::<u64>::each(&*#sketch, &mut |s| #id = Some(*s));
}
}
_ => quote!(),
}
}
/// A pattern that binds every field of `fields` by name (`_0`, `_1`… for tuple fields), and the
/// body of walk `which` over them.
fn arm(fields: &Fields, which: Which, mutable: bool) -> syn::Result<(Tokens, Tokens)> {
let mut members = Vec::new();
let mut walked: Vec<(Ident, Roles)> = Vec::new();
for (i, field) in fields.iter().enumerate() {
let b = field.ident.clone().unwrap_or_else(|| format_ident!("_{}", i));
members.push(field.ident.clone().map_or_else(|| Member::Unnamed(i.into()), Member::Named));
walked.push((b, roles(field)?));
}
let bindings: Vec<Ident> = walked.iter().map(|(b, _)| b.clone()).collect();
// `after = x` moves a field to just after `x`.
for b in &bindings {
let Some(pos) = walked.iter().position(|(x, _)| x == b) else { continue };
let Some(after) = walked.get(pos).and_then(|(_, r)| r.after.clone()) else { continue };
let moved = walked.remove(pos);
let Some(at) = walked.iter().position(|(x, _)| *x == after) else {
return Err(syn::Error::new_spanned(after, "no such field"));
};
walked.insert(at + 1, moved);
}
let mut body = Tokens::new();
for (b, r) in &walked {
body.extend(pre_values(b, r, which));
}
for (b, r) in &walked {
body.extend(walk_field(b, r, which, mutable));
}
let pattern = match fields {
Fields::Named(_) => quote!({ #(#bindings),* }),
Fields::Unnamed(_) => quote!({ #(#members: #bindings),* }),
Fields::Unit => quote!(),
};
Ok((pattern, body))
}
fn expand(input: &DeriveInput) -> syn::Result<Tokens> {
let name = &input.ident;
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
let w = quote!(crate::walk);
let mut methods = Tokens::new();
for (which, method, item) in
[(Which::Exprs, "walk_exprs", "ExprItem"), (Which::Refs, "walk_refs", "RefItem"), (Which::Geometry, "walk_geometry", "GeoItem")]
{
for mutable in [false, true] {
let body = match &input.data {
Data::Struct(s) => {
let (pattern, body) = arm(&s.fields, which, mutable)?;
quote! { let Self #pattern = self; #body }
}
Data::Enum(e) => {
let mut arms = Tokens::new();
for v in &e.variants {
let ident = &v.ident;
let (pattern, body) = arm(&v.fields, which, mutable)?;
arms.extend(quote! { Self::#ident #pattern => { #body } });
}
quote! { match self { #arms } }
}
Data::Union(u) => return Err(syn::Error::new_spanned(u.union_token, "unions are not walked")),
};
let (method, item, r) = if mutable {
(format_ident!("{}_mut", method), format_ident!("{}Mut", item), quote!(&'a mut))
} else {
(format_ident!("{}", method), format_ident!("{}", item), quote!(&'a))
};
methods.extend(quote! {
#[allow(unused_variables, unused_mut, clippy::needless_borrow)]
fn #method<'a>(#r self, f: &mut dyn FnMut(#w::#item<'a>)) { #body }
});
}
}
Ok(quote! {
impl #impl_generics #w::Walk for #name #ty_generics #where_clause { #methods }
})
}
+2
View File
@@ -12,6 +12,8 @@ workspace = true
solidcraft-geom = { workspace = true }
solidcraft-sketch = { workspace = true }
solidcraft-kernel = { workspace = true }
solidcraft-render = { workspace = true }
solidcraft-derive = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+37 -3
View File
@@ -12,11 +12,34 @@ use crate::Document;
/// A look: a name (a library entry's, or "Custom"), sRGB colour and opacity (1 = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "LookWire")]
pub struct Look {
pub name: String,
pub color: [u8; 3],
#[serde(default = "opaque")]
pub opacity: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pbr: Option<crate::pbr_appearance::PbrParameters>,
}
#[derive(Deserialize)]
struct LookWire {
name: String,
color: [u8; 3],
#[serde(default = "opaque")]
opacity: f64,
#[serde(default)]
pbr: Option<crate::pbr_appearance::PbrParameters>,
}
impl TryFrom<LookWire> for Look {
type Error = String;
fn try_from(value: LookWire) -> std::result::Result<Self, Self::Error> {
if value.pbr.is_some() && value.opacity != 1.0 {
return Err("opaque PBR requires opacity 1; refraction/transmission is not supported".into());
}
Ok(Self { name: value.name, color: value.color, opacity: value.opacity, pbr: value.pbr })
}
}
fn opaque() -> f64 {
@@ -25,7 +48,13 @@ fn opaque() -> f64 {
impl Look {
pub fn custom(color: [u8; 3], opacity: f64) -> Look {
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0) }
Look { name: "Custom".into(), color, opacity: opacity.clamp(0.0, 1.0), pbr: None }
}
pub fn opaque_surface(&self) -> crate::Result<Option<solidcraft_render::surface::OpaqueSurface>> {
if self.pbr.is_some() && self.opacity != 1.0 {
return Err(crate::DocError::Invalid("opaque PBR requires opacity 1".into()));
}
self.pbr.map(|p| p.surface(self.color).map_err(|e| crate::DocError::Invalid(e.to_string()))).transpose()
}
/// Colour as 0–1 fractions.
pub fn rgb(&self) -> [f32; 3] {
@@ -109,14 +138,19 @@ pub use defaults::LIBRARY;
/// A library appearance by name (case-insensitive).
pub fn library(name: &str) -> Option<Look> {
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o })
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look {
name: n.to_string(),
color: *c,
opacity: *o,
pbr: None,
})
}
/// The look a physical material gives a body that has no appearance of its own.
pub fn material_look(material: &str) -> Option<Look> {
let color = crate::material_color(material)?;
let opacity = if material == "Glass" { 0.25 } else { 1.0 };
Some(Look { name: material.to_string(), color, opacity })
Some(Look { name: material.to_string(), color, opacity, pbr: None })
}
impl Document {
+1
View File
@@ -205,6 +205,7 @@ impl FeatureKind {
/// Map the feature's world geometry (points, directions, explicit planes) by `m`.
pub fn transform_geometry(&mut self, m: &Mat) {
use crate::feature_geometry::Geo;
use crate::walk::Walk;
self.walk_geometry_mut(&mut |g| match g {
Geo::Pick(p) | Geo::Pos(p) => *p = apply_point(m, *p),
Geo::Dir(v) => *v = apply_vector(m, *v),
+5
View File
@@ -194,6 +194,7 @@ impl crate::Feature {
| K::SurfaceTrim { plane, .. } => checked_plane(plane, 0)?,
K::Draft { neutral, .. } => checked_plane(neutral, 0)?,
K::ReplaceFace { target, .. } => checked_plane(target, 0)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => checked_plane(plane, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs() {
checked_geo(r, 0)?;
@@ -234,6 +235,9 @@ impl crate::Feature {
mapped_name(n, parameters);
}
}
for e in sketch.text_exprs_mut() {
*e = clone_expression(e, parameters);
}
for link in &mut sketch.links {
remap_link(&mut link.source, ids, names, bodies, 0);
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
@@ -248,6 +252,7 @@ impl crate::Feature {
| K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?,
K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?,
K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?,
K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, names, bodies, 0)?,
K::ConstructionAxis { def } => {
for r in def.refs_mut() {
remap_geo(r, ids, names, bodies, 0)?;
+9 -4
View File
@@ -125,10 +125,15 @@ fn construct<'a>(st: &'a ModelState, name: &str) -> Result<&'a ConstructGeom> {
/// The face picked: (body, face index, point on it).
fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&'a ModelBody, usize, Vec3)> {
let at = match name.as_deref().and_then(|n| crate::naming::point_on_face(st, n, at)) {
Some((q, _)) => q,
None => at,
};
// The named face itself: its nearest point to the old pick can lie on an edge it shares
// (a corner round that a parameter moved away from the pick), where picking again by the
// point finds the flat face beside it (#1255).
if let Some(hit) = name.as_deref().and_then(|n| crate::name_lookup::locate_face(st, n, at))
&& let Some(b) = st.bodies.get(hit.body).filter(|b| !b.body.is_mesh())
&& hit.face < b.body.face_count()
{
return Ok((b, hit.face, hit.point));
}
for b in bodies(st) {
if let Some(fi) = crate::appearance::face_index_at(b, at) {
return Ok((b, fi, at));
+248
View File
@@ -0,0 +1,248 @@
//! How finely the viewport tessellates curved faces and edges (Preferences ▸ Graphics ▸ Display
//! quality). A viewing preference, not design data: it only picks the tolerances of the display
//! plan ([`crate::ModelBody::display_plan`]) the viewport draws. The reference mesh the model
//! reads (picks, holes, measuring) and exports keep their own tolerances.
use std::sync::atomic::{AtomicU8, Ordering};
/// A display quality level.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum DisplayQuality {
Coarse,
#[default]
Normal,
Fine,
Ultra,
}
impl DisplayQuality {
pub const ALL: [DisplayQuality; 4] = [DisplayQuality::Coarse, DisplayQuality::Normal, DisplayQuality::Fine, DisplayQuality::Ultra];
/// The id kept in the settings file.
pub fn id(self) -> &'static str {
match self {
DisplayQuality::Coarse => "coarse",
DisplayQuality::Normal => "normal",
DisplayQuality::Fine => "fine",
DisplayQuality::Ultra => "ultra",
}
}
pub fn parse(id: &str) -> Option<DisplayQuality> {
Self::ALL.into_iter().find(|q| q.id() == id)
}
/// Position in [`DisplayQuality::ALL`] (indexes the per-level display caches).
pub fn index(self) -> usize {
self as usize
}
/// Chord tolerance as a fraction of the body's size: the largest gap allowed between a
/// facet and its surface.
pub fn chord_fraction(self) -> f64 {
match self {
DisplayQuality::Coarse => 3e-3,
DisplayQuality::Normal => 1e-3,
DisplayQuality::Fine => 3e-4,
DisplayQuality::Ultra => 1e-4,
}
}
/// Smallest chord tolerance (mm), so a tiny body does not mesh down to nothing.
pub fn min_chord(self) -> f64 {
match self {
DisplayQuality::Coarse => 3e-3,
DisplayQuality::Normal => 1e-3,
DisplayQuality::Fine => 3e-4,
DisplayQuality::Ultra => 1e-4,
}
}
/// Most a facet of a curved face or edge may turn (radians), so a small radius on a large
/// body still looks round.
pub fn angle(self) -> f64 {
let deg: f64 = match self {
DisplayQuality::Coarse => 30.0,
DisplayQuality::Normal => 20.0,
DisplayQuality::Fine => 12.0,
DisplayQuality::Ultra => 7.0,
};
deg.to_radians()
}
/// The chord tolerance (mm) for a body of this size.
pub fn chord(self, size: f64) -> f64 {
let size = if size.is_finite() && size > 0.0 { size } else { 0.0 };
(size * self.chord_fraction()).max(self.min_chord())
}
}
/// Most refinement steps view-dependent display refinement takes past the chosen level (#1320):
/// each halves the chord tolerance, so the finest is 1/16 of the chosen level's.
pub const MAX_REFINE: u8 = 4;
/// Screen-space error (physical pixels) above which a body is refined: a facet's sag shows.
pub const REFINE_PX: f64 = 1.0;
/// Error a refinement aims for (below [`REFINE_PX`], so a body just refined is not refined again
/// at the next small zoom step).
pub const TARGET_PX: f64 = 0.75;
/// Error below which a refined body steps back toward the chosen level. A refinement lands
/// between half [`TARGET_PX`] and [`TARGET_PX`], and a step back between this and twice this, so
/// zooming out or in by up to 2× from either never flips the level back.
pub const COARSEN_PX: f64 = 0.175;
impl DisplayQuality {
/// Chord tolerance (mm) of a body of this size refined `k` steps past this level: halved
/// per step.
pub fn refined_chord(self, size: f64, k: u8) -> f64 {
self.chord(size) / f64::from(1u32 << k.min(MAX_REFINE))
}
/// Facet angle (radians) `k` steps past this level: an arc's sag goes with the square of its
/// angle, so the angle shrinks by √2 per step, keeping pace with the chord.
pub fn refined_angle(self, k: u8) -> f64 {
self.angle() / std::f64::consts::SQRT_2.powi(i32::from(k.min(MAX_REFINE)))
}
}
/// The refinement step a body should be drawn at (0: the chosen level), from `err_px`: the
/// screen-space error of the chosen level, its chord tolerance in physical pixels where the body
/// is nearest the eye (0 for a body off screen or too small to matter). `current` is the step
/// drawn now; steps halve the error. A body is refined once its error passes [`REFINE_PX`], to the
/// first step under [`TARGET_PX`], and steps back only once the error falls under [`COARSEN_PX`],
/// to the coarsest step that stays under twice that: the band between keeps an orbit or a small
/// zoom from flipping levels. Never past `max` (at most [`MAX_REFINE`]).
pub fn refinement_for(err_px: f64, current: u8, max: u8) -> u8 {
let max = max.min(MAX_REFINE);
let current = current.min(max);
if !(err_px.is_finite() && err_px > 0.0) {
return 0;
}
let err = |k: u8| err_px / f64::from(1u32 << k);
if err(current) > REFINE_PX {
(current..=max).find(|&k| err(k) <= TARGET_PX).unwrap_or(max)
} else if err(current) < COARSEN_PX {
(0..=current).find(|&k| err(k) <= 2.0 * COARSEN_PX).unwrap_or(current)
} else {
current
}
}
static CURRENT: AtomicU8 = AtomicU8::new(1);
/// The level the viewport draws at (process-wide; set from the preferences).
pub fn current() -> DisplayQuality {
DisplayQuality::ALL.get(CURRENT.load(Ordering::Relaxed) as usize).copied().unwrap_or_default()
}
/// Set the level the viewport draws at. Bodies keep a display plan per level, so switching back
/// is instant and nothing is re-evaluated.
pub fn set_current(q: DisplayQuality) {
CURRENT.store(q.index() as u8, Ordering::Relaxed);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normal_is_the_application_tolerance_and_levels_get_finer() {
let n = DisplayQuality::Normal;
assert_eq!(n.chord(200.0), 0.2);
assert_eq!(n.chord(0.5), 1e-3);
assert!((n.angle() - solidcraft_kernel::DISPLAY_ANGLE).abs() < 1e-12);
for w in DisplayQuality::ALL.windows(2) {
assert!(w[1].chord(100.0) < w[0].chord(100.0) && w[1].angle() < w[0].angle());
}
for q in DisplayQuality::ALL {
assert_eq!(DisplayQuality::parse(q.id()), Some(q));
assert_eq!(DisplayQuality::ALL[q.index()], q);
}
}
/// Each level keeps its own plan: finer levels draw a round body with more triangles and
/// closer to the surface, and the reference mesh the model reads never changes.
#[test]
fn each_level_has_its_own_plan_and_the_reference_mesh_stays() {
use solidcraft_geom::Vec3;
let body = solidcraft_kernel::cylinder(Vec3::ZERO, Vec3::Z, 20.0, 50.0).unwrap();
let mb = crate::ModelBody::new("Body1".into(), body, 1);
let reference = mb.mesh();
let mut last = 0;
for q in DisplayQuality::ALL {
let mesh = mb.plan_mesh_at(q).unwrap();
let r = |v: Vec3| (v.x * v.x + v.y * v.y).sqrt();
// The worst chord's sag on the side wall, from its triangle centres.
let sag = mesh
.triangles
.iter()
.map(|t| t.map(|i| mesh.positions[i as usize]))
.filter(|t| t.iter().all(|p| (r(*p) - 20.0).abs() < 1e-9))
.map(|[a, b, c]| 20.0 - r((a + b + c) * (1.0 / 3.0)))
.fold(0.0, f64::max);
assert!(sag <= q.chord(mb.body.size()) * 1.01, "{q:?}: sag {sag}");
assert!(mesh.triangles.len() > last, "{q:?}: {} triangles", mesh.triangles.len());
last = mesh.triangles.len();
assert!(std::sync::Arc::ptr_eq(&mesh, &mb.plan_mesh_at(q).unwrap()), "made once per level");
}
assert!(std::sync::Arc::ptr_eq(&reference, &mb.mesh()));
assert_eq!(mb.display_plan_at(DisplayQuality::Normal).unwrap().vertex_count(), {
let plan = mb.body.display_plan_angular((mb.body.size() * 1e-3).max(1e-3), solidcraft_kernel::DISPLAY_ANGLE).unwrap();
plan.vertex_count()
});
}
#[test]
fn refinement_follows_the_screen_space_error_with_hysteresis() {
// Off screen or tiny: the chosen level.
assert_eq!(refinement_for(0.0, 3, MAX_REFINE), 0);
assert_eq!(refinement_for(f64::NAN, 2, MAX_REFINE), 0);
// Fine already: stays.
assert_eq!(refinement_for(0.6, 0, MAX_REFINE), 0);
assert_eq!(refinement_for(1.0, 0, MAX_REFINE), 0);
// Past a pixel: the first step under the target (each halves the error).
assert_eq!(refinement_for(1.2, 0, MAX_REFINE), 1);
assert_eq!(refinement_for(5.0, 0, MAX_REFINE), 3);
assert_eq!(refinement_for(5.0, 1, MAX_REFINE), 3);
// Capped.
assert_eq!(refinement_for(1e6, 0, MAX_REFINE), MAX_REFINE);
assert_eq!(refinement_for(1e6, 0, 2), 2);
assert_eq!(refinement_for(1e6, 9, 99), MAX_REFINE);
// Zooming back out: kept until the error is well under a pixel, then the coarsest step
// that stays under half a pixel.
assert_eq!(refinement_for(5.0, 3, MAX_REFINE), 3);
assert_eq!(refinement_for(2.5, 3, MAX_REFINE), 3, "0.31 px at step 3: in the band");
assert_eq!(refinement_for(1.2, 3, MAX_REFINE), 2, "0.15 px at step 3: down to 0.3 px");
assert_eq!(refinement_for(0.05, 3, MAX_REFINE), 0);
// No flicker: any error, once settled, gives the same step again, and a zoom by up to
// 2x either way from where a level was chosen keeps it.
let mut e = 0.01;
while e < 1e4 {
for start in 0..=MAX_REFINE {
let k = refinement_for(e, start, MAX_REFINE);
assert_eq!(refinement_for(e, k, MAX_REFINE), k, "error {e}, from {start}");
// Settled by zooming in (from the chosen level) or out (from the finest).
if start == 0 || start == MAX_REFINE {
for f in [0.5, 0.7, 1.0, 1.4, 2.0] {
let back = refinement_for(e * f, k, MAX_REFINE);
let zoomed_in = f >= 1.0;
assert!(
back == k || (zoomed_in && back > k && start == 0) || (!zoomed_in && back < k && start == MAX_REFINE),
"error {e} x{f} from {start}: {k} -> {back}"
);
}
}
}
e *= 1.13;
}
}
#[test]
fn refined_steps_halve_the_chord() {
let q = DisplayQuality::Normal;
assert_eq!(q.refined_chord(200.0, 0), q.chord(200.0));
assert_eq!(q.refined_chord(200.0, 2), q.chord(200.0) / 4.0);
assert_eq!(q.refined_chord(200.0, 99), q.chord(200.0) / 16.0);
assert!((q.refined_angle(2) - q.angle() / 2.0).abs() < 1e-12);
}
}
File diff suppressed because it is too large Load Diff
+293 -75
View File
@@ -30,10 +30,10 @@ pub struct ModelBody {
mesh: Arc<OnceLock<Arc<Mesh>>>,
/// The display plan (none when the body has no plan: a mesh body, or one the planner
/// refuses), shared like the mesh. A GPU viewport draws it.
plan: Arc<OnceLock<Option<Arc<TessPlan>>>>,
plan: Arc<[OnceLock<Option<Arc<TessPlan>>>; 4]>,
/// The plan evaluated on the CPU (none without a plan), shared like the mesh: the triangles a
/// GPU viewport draws, so a highlight over a face lies exactly on it.
plan_mesh: Arc<OnceLock<Option<Arc<Mesh>>>>,
plan_mesh: Arc<[OnceLock<Option<Arc<Mesh>>>; 4]>,
/// Identity of this body's content for the evaluation cache: the key of the evaluation
/// that produced it (kept while later features pass it through unchanged).
key: u64,
@@ -61,8 +61,8 @@ impl ModelBody {
body,
feature,
mesh: Arc::new(OnceLock::new()),
plan: Arc::new(OnceLock::new()),
plan_mesh: Arc::new(OnceLock::new()),
plan: Arc::new(Default::default()),
plan_mesh: Arc::new(Default::default()),
key: 0,
names: Default::default(),
made: Default::default(),
@@ -88,11 +88,19 @@ impl ModelBody {
.clone()
}
/// The display plan of this body (the same tolerance and angle as [`ModelBody::mesh`]), made
/// once; `None` when the body has no plan (a mesh body, or one the planner refuses).
/// The display plan of this body at the viewport's display quality
/// ([`crate::display_quality::current`]; at Normal, the same tolerance and angle as
/// [`ModelBody::mesh`]), made once per level; `None` when the body has no plan (a mesh body,
/// or one the planner refuses).
pub fn display_plan(&self) -> Option<Arc<TessPlan>> {
self.display_plan_at(crate::display_quality::current())
}
/// The display plan at a given display quality, made once per level.
pub fn display_plan_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<TessPlan>> {
self.plan
.get_or_init(|| match self.body.display_plan_angular(self.display_tol(), kernel::DISPLAY_ANGLE) {
.get(q.index())?
.get_or_init(|| match self.body.display_plan_angular(q.chord(self.body.size()), q.angle()) {
Ok(plan) => Some(Arc::new(plan)),
Err(e) => {
log::debug!("{}: no display plan ({e})", self.name);
@@ -102,12 +110,39 @@ impl ModelBody {
.clone()
}
/// The display plan's mesh, evaluated on the CPU once: the same triangles a GPU viewport
/// evaluates and draws for this body. Highlights over a face drawn from the plan use it, since
/// the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and the two
/// would fight at equal depth. `None` when the body has no plan.
/// The display plan's mesh, evaluated on the CPU once per level: the same triangles a GPU
/// viewport evaluates and draws for this body. Highlights over a face drawn from the plan use
/// it, since the reference mesh ([`ModelBody::mesh`]) triangulates the face differently and
/// the two would fight at equal depth. `None` when the body has no plan.
pub fn plan_mesh(&self) -> Option<Arc<Mesh>> {
self.plan_mesh.get_or_init(|| self.display_plan().and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new)).clone()
self.plan_mesh_at(crate::display_quality::current())
}
/// The display plan's mesh at a given display quality.
pub fn plan_mesh_at(&self, q: crate::display_quality::DisplayQuality) -> Option<Arc<Mesh>> {
self.plan_mesh
.get(q.index())?
.get_or_init(|| self.display_plan_at(q).and_then(|plan| self.body.display_mesh_of(&plan).ok()).map(Arc::new))
.clone()
}
/// The display plan and its mesh `k` steps finer than `q` (view-dependent refinement, #1320;
/// see [`crate::display_quality::refinement_for`]). Step 0 is the cached plan at `q`; finer
/// steps are made on each call and kept by the viewport, which evicts them when it runs over
/// its budget. `None` when the body has no plan at that step.
pub fn refined_display(&self, q: crate::display_quality::DisplayQuality, k: u8) -> Option<(Arc<TessPlan>, Arc<Mesh>)> {
if k == 0 {
return Some((self.display_plan_at(q)?, self.plan_mesh_at(q)?));
}
let plan = match self.body.display_plan_angular(q.refined_chord(self.body.size(), k), q.refined_angle(k)) {
Ok(plan) => plan,
Err(e) => {
log::debug!("{}: no display plan refined {k} steps ({e})", self.name);
return None;
}
};
let mesh = self.body.display_mesh_of(&plan).ok()?;
Some((Arc::new(plan), Arc::new(mesh)))
}
/// The display mesh's chord tolerance: 1/1000 of the body's size.
@@ -702,11 +737,13 @@ pub struct Model {
pub no_cache: bool,
}
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
/// sketch's texts), sorted.
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.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
names.sort();
names.dedup();
@@ -1272,7 +1309,7 @@ fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) }
}
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
pub(crate) fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
@@ -1281,6 +1318,17 @@ fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
Document::eval_in(vals, e, k)
}
/// A loft builds each section from its outline alone, so a section with a hole (a ring) is
/// refused rather than lofted as if it were filled.
fn refuse_loft_holes(r: &Region2) -> Result<()> {
if r.holes.is_empty() {
return Ok(());
}
Err(DocError::Invalid(
"not supported yet: a loft section with a hole; loft the outline and the hole as two bodies and cut one from the other".into(),
))
}
pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
let ps = &ss.profiles;
if ps.is_empty() {
@@ -1667,11 +1715,62 @@ fn region_samples(r: &Region2) -> Vec<Vec2> {
out
}
/// The region with each hole filled whose inside the tool would sweep with no change to the
/// targets: all empty for a cut (the profile drawn round a hole in the part), all material for a
/// join. Then the tool's walls don't lie on the part's walls there (the boolean can't intersect
/// walls lying on each other, #1360), and what it cuts or joins is the same. A hole is filled only
/// when points across it at levels through the tool are all empty (or all material) and no
/// vertex of the targets lies inside the column over it (an island in it, a floor).
fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[String], plane: &Plane, (lo, hi): (f64, f64)) -> Region2 {
if r.holes.is_empty() || !matches!(op, Operation::Cut | Operation::Join) || hi - lo <= 0.0 {
return r.clone();
}
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect();
if bodies.is_empty() {
return r.clone();
}
let meshes: Vec<Arc<Mesh>> = bodies.iter().map(|b| b.mesh()).collect();
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
let margin = (size * 1e-4).max(1e-4);
let n = plane.normal();
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
let want = op == Operation::Join;
let mut holes = Vec::with_capacity(r.holes.len());
for h in &r.holes {
let column = Region2 { outer: h.ccw(), holes: vec![] };
let samples = region_samples(&column);
let swept = (1..=8).all(|k| {
let z = lo + (hi - lo) * (k as f64 - 0.5) / 8.0;
samples.iter().all(|s| inside_any(plane.to_world(*s) + n * z) == want)
});
// No boundary of the targets within the column (shrunk off its wall, short of its ends).
let clear = swept
&& meshes.iter().all(|m| {
m.positions.iter().all(|p| {
let z = (*p - plane.origin).dot(n);
let q = plane.to_local(*p);
!(z > lo + margin
&& z < hi - margin
&& column.contains(q)
&& h.segs.iter().all(|sg| sg.point_at(sg.closest_param(q)).dist(q) > margin))
})
});
if !clear {
holes.push(h.clone());
}
}
Region2 { outer: r.outer.clone(), holes }
}
/// Grow profile edges that lie on a face of a target body away from the profile, by a small
/// amount, when the space they grow into is already material (join) or empty (cut). `probe`
/// maps a sketch point to the world points where the tool would be (along the extrude, around
/// the revolve). Only loops of straight edges are adjusted.
/// the revolve). Loops of lines and arcs are adjusted: a line moves out, an arc keeps its centre
/// and changes its radius (a rounded corner of a face cut whole, #1275). Arcs move only where
/// both of their ends meet tangent neighbours that move with them; otherwise the loop is left
/// as it is.
fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, targets: &[String], probe: &dyn Fn(Vec2) -> Vec<Vec3>) -> Region2 {
use solidcraft_geom::Seg2;
if !matches!(op, Operation::Cut | Operation::Join) {
return r.clone();
}
@@ -1683,55 +1782,94 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
let delta = (size * 0.005).max(1e-3);
let lp = r.outer.ccw();
let lines: Option<Vec<(Vec2, Vec2)>> =
lp.segs.iter().map(|s| if let solidcraft_geom::Seg2::Line { a, b } = *s { Some((a, b)) } else { None }).collect();
let Some(lines) = lines else { return r.clone() };
let segs = &lp.segs;
if segs.is_empty() || !segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
return r.clone();
}
// The outward normal (away from the profile) at parameter `t` of a segment of the
// counterclockwise loop: the tangent turned clockwise.
let out_at = |s: &Seg2, t: f64| -> Option<Vec2> {
match *s {
Seg2::Line { a, b } => (b - a).normalized().map(|d| Vec2::new(d.y, -d.x)),
Seg2::Arc { start, sweep, .. } => Some(Vec2::from_angle(start + sweep * t) * sweep.signum()),
_ => None,
}
};
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
let mut grow = vec![0.0; lines.len()];
let mut grow = vec![0.0; segs.len()];
let mut any = false;
for (i, (a, b)) in lines.iter().enumerate() {
let Some(dir) = (*b - *a).normalized() else { continue };
let out = Vec2::new(dir.y, -dir.x);
for (i, seg) in segs.iter().enumerate() {
if let Seg2::Arc { radius, sweep, .. } = *seg
&& sweep < 0.0
&& radius <= 2.0 * delta
{
// A concave arc too small to shrink by the step.
continue;
}
// Is the edge on a body face? Material on exactly one side of it.
let mids = [a.lerp(*b, 0.25), a.lerp(*b, 0.5), a.lerp(*b, 0.75)];
let side = |k: f64| mids.iter().flat_map(|m| probe(*m + out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
let mids: Vec<(Vec2, Vec2)> = [0.25, 0.5, 0.75].iter().filter_map(|t| Some((seg.point_at(*t), out_at(seg, *t)?))).collect();
if mids.len() < 3 {
continue;
}
let side = |k: f64| mids.iter().flat_map(|(m, out)| probe(*m + *out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
let (outer, inner) = (side(0.5), side(-0.5));
let want_outer = op == Operation::Join;
let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer);
let room = side(1.0).iter().all(|x| *x == want_outer);
if on_face && room {
if let Some(g) = grow.get_mut(i) {
*g = delta;
}
if on_face
&& room
&& let Some(g) = grow.get_mut(i)
{
*g = delta;
any = true;
}
}
if !any {
return r.clone();
}
// Offset the chosen lines and re-intersect neighbours.
let n = lines.len();
let shifted: Vec<(Vec2, Vec2)> = lines
.iter()
.zip(&grow)
.map(|((a, b), g)| {
let dir = (*b - *a).normalized().unwrap_or(Vec2::X);
let out = Vec2::new(dir.y, -dir.x);
(*a + out * *g, dir)
})
.collect();
let mut pts = Vec::with_capacity(n);
let n = segs.len();
let tangent = |a: &Seg2, b: &Seg2| a.end_tangent().cross(b.start_tangent()).abs() < 1e-6 && a.end_tangent().dot(b.start_tangent()) > 0.0;
// Where segment `i` starts once grown: the corner of it and the one before.
let mut starts = Vec::with_capacity(n);
for i in 0..n {
let (Some(&(p0, d0)), Some(&(p1, d1))) = (shifted.get((i + n - 1) % n), shifted.get(i)) else { return r.clone() };
let (Some(prev), Some(cur), Some(&g0), Some(&g1)) = (segs.get((i + n - 1) % n), segs.get(i), grow.get((i + n - 1) % n), grow.get(i)) else {
return r.clone();
};
let corner = cur.start();
if g0 == 0.0 && g1 == 0.0 {
starts.push(corner);
continue;
}
if tangent(prev, cur) {
// Moved together, a tangent joint moves along the common normal and stays tangent.
if g0 != g1 {
return r.clone();
}
let Some(out) = out_at(cur, 0.0) else { return r.clone() };
starts.push(corner + out * g1);
continue;
}
let (Seg2::Line { a: a0, b: b0 }, Seg2::Line { a: a1, b: b1 }) = (*prev, *cur) else { return r.clone() };
let (Some(d0), Some(d1)) = ((b0 - a0).normalized(), (b1 - a1).normalized()) else { return r.clone() };
let (p0, p1) = (a0 + Vec2::new(d0.y, -d0.x) * g0, a1 + Vec2::new(d1.y, -d1.x) * g1);
let den = d0.cross(d1);
if den.abs() < 1e-12 {
pts.push(p1);
starts.push(p1);
continue;
}
let t = (p1 - p0).cross(d1) / den;
pts.push(p0 + d0 * t);
starts.push(p0 + d0 * t);
}
Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: r.holes.clone() }
let mut out = Vec::with_capacity(n);
for (i, seg) in segs.iter().enumerate() {
let (Some(&a), Some(&b), Some(&g)) = (starts.get(i), starts.get((i + 1) % n), grow.get(i)) else { return r.clone() };
out.push(match *seg {
Seg2::Line { .. } => Seg2::Line { a, b },
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius: radius + g * sweep.signum(), start, sweep },
_ => return r.clone(),
});
}
Region2 { outer: solidcraft_geom::Loop2 { segs: out }, holes: r.holes.clone() }
}
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
@@ -2249,17 +2387,24 @@ fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Featu
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out
/// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the
/// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
/// refuses it).
fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<more::PrimitivePrism>)]) -> Result<Vec<String>> {
/// refuses it). Pieces the hole parts from a sheet's base are named in `warning`.
fn sheet_hole_cut(
st: &mut ModelState,
f: &Feature,
tools: &[(Traced, Option<more::PrimitivePrism>)],
warning: &mut Option<String>,
) -> Result<Vec<String>> {
if st.sheets.is_empty() {
return Ok(Vec::new());
}
let saved = (st.sheets.clone(), st.bodies.clone());
let mut handled: Vec<String> = Vec::new();
// Noted only when every tool was machined here.
let mut parted = None;
for (tool, prism) in tools {
let tool = &tool.body;
let done = match prism {
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true) {
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true, &mut parted) {
Ok(done) => done,
// Drilled along a panel (into a rim) the hole is no flat cut-out: a bore of
// that panel, when it stays inside it.
@@ -2295,6 +2440,9 @@ fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<mor
}
}
}
if let Some(note) = parted {
append_warning(warning, note);
}
Ok(handled)
}
@@ -2656,6 +2804,8 @@ fn feature_tools_with_constructor(
];
// How far a tool grows past its profile at either end, at most.
let mut growth: f64 = 0.0;
// A face of the targets in the profile's plane (the same for every profile).
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets);
let tools: Vec<Traced> = regions
.iter()
.map(|r| {
@@ -2669,12 +2819,7 @@ fn feature_tools_with_constructor(
// A start that can't run on (the profile hangs over the face's edge):
// the sides stand straight for a hair first, so the boolean can push the
// face the tool stands on into the body.
let foot = if a >= 0.0 && matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets)
{
TAPER_FOOT.min((b - a) * 0.01)
} else {
0.0
};
let foot = if a >= 0.0 && on_face { TAPER_FOOT.min((b - a) * 0.01) } else { 0.0 };
let body = kernel::extrude_tapered_between(&start_plane, r, a, b, foot, sign, taper)?;
growth = growth.max(a.abs().max(b.abs()) * taper.tan().abs());
Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None))
@@ -2729,6 +2874,8 @@ fn feature_tools_with_constructor(
let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all;
// The footprint of each tool in the sketch: a tool lies in the prism over it.
let mut footprints = Vec::with_capacity(regions.len());
// Each tool's extent along the normal (they differ where one is run on into a face).
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
for r in &regions {
if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
@@ -2742,6 +2889,7 @@ fn feature_tools_with_constructor(
// the sewable seam with a coincident curved intersection.
tools.extend(exact);
footprints.push(r.clone());
extents.push((lo + off, hi + off));
continue;
}
}
@@ -2749,18 +2897,31 @@ fn feature_tools_with_constructor(
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
let original = r;
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
let r = fill_void_holes(st, &r, *operation, targets, &ss.plane, (l2, h2));
untrimmed &= l2 == lo + off && h2 == hi + off && &r == original;
tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?);
footprints.push(r);
extents.push((l2, h2));
}
// A cut by profiles apart in the sketch (the letters of a text): each tool lies in the
// prism over its footprint, so the tools are apart too (whatever their heights, and
// trimmed or not). The body is then cut once by all of them rather than once per
// profile, each cut working on a body grown by the one before.
// (Sheet metal keeps its own way of cutting, tool by tool, to keep its flat pattern.)
// A cut or a join by profiles apart in the sketch (the letters of a text): each tool
// lies in the prism over its footprint, so the tools are apart too (whatever their
// heights, and trimmed or not). The body is then cut once by all of them, or joined to
// all of them at once, rather than once per profile, each on a body grown or cut by
// the ones before. (A join meeting several bodies keeps joining them tool by tool;
// sheet metal keeps its own way of cutting, tool by tool, to keep its flat pattern.)
let sheet =
if targets.is_empty() { !st.sheets.is_empty() } else { targets.iter().any(|t| st.sheets.iter().any(|sheet| &sheet.body == t)) };
if *operation == Operation::Cut && !sheet && tools.len() > 1 && regions_apart(&footprints, APART_GAP) {
let one_target = if targets.is_empty() { st.bodies.len() == 1 } else { targets.len() == 1 };
// A join at once only of tools alike in height (one prism of them all, which the
// boolean joins exactly): tools that start at different levels (some run on into the
// face, one hanging over its edge) keep joining one by one.
let alike = extents.windows(2).all(|w| (w[0].0 - w[1].0).abs() < 1e-9 && (w[0].1 - w[1].1).abs() < 1e-9);
let at_once = match operation {
Operation::Cut => !sheet,
Operation::Join => one_target && !sheet && alike,
_ => false,
};
if at_once && tools.len() > 1 && regions_apart(&footprints, APART_GAP) {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
if let Ok(Some(one)) = kernel::join_apart(&bodies) {
tools = vec![Traced::step(one, &tools.iter().collect::<Vec<_>>())];
@@ -2953,6 +3114,7 @@ fn feature_tools_with_constructor(
let ss = st.sketch(prof.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &prof.profiles)?);
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
refuse_loft_holes(r)?;
let ps = st.sketch(pt.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", pt.sketch)))?;
let id = pt.point.as_deref().unwrap_or_default();
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
@@ -3008,6 +3170,7 @@ fn feature_tools_with_constructor(
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?;
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
let [r] = &regions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
refuse_loft_holes(r)?;
secs.push((ss.plane, r.outer.clone()));
sketches.push(ss);
}
@@ -3017,7 +3180,8 @@ fn feature_tools_with_constructor(
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
Ok(vec![roles.tool(body)])
}
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => {
let ctl = sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -3035,10 +3199,10 @@ fn feature_tools_with_constructor(
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
// A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? {
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect();
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect();
}
let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect()
regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect()
}
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps =
@@ -3086,6 +3250,32 @@ fn feature_tools_with_constructor(
}
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
/// A sweep's options from its stored orientation and its twist and end scale expressions
/// (none: no twist, scale 1). Out-of-range values are refused by the kernel.
pub(crate) fn sweep_control(
vals: &BTreeMap<String, Value>,
orientation: &crate::SweepOrientation,
twist: &Option<String>,
scale: &Option<String>,
) -> Result<kernel::SweepControl> {
let orientation = match *orientation {
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
};
let twist = match twist {
Some(t) => val(vals, t, Kind::Angle)?,
None => 0.0,
};
let scale = match scale {
Some(k) => val(vals, k, Kind::Unitless)?,
None => 1.0,
};
let ctl = kernel::SweepControl { orientation, twist, scale };
ctl.check()?;
Ok(ctl)
}
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
let holes = match wall {
@@ -3281,7 +3471,11 @@ pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Exten
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
(None, crate::Direction::Positive) => (0.0, d),
(None, crate::Direction::Negative) => (-d, 0.0),
(None, crate::Direction::Symmetric) => (-d, d),
(None, crate::Direction::Symmetric) => {
// Through All is a reach computed from target bodies, not an entered total.
let half = if extent.through_all { d } else { extent.convention.symmetric_half_distance(d) };
(-half, half)
}
})
}
@@ -3928,7 +4122,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SurfaceRevolve { .. }
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceOffset { .. } => surface::eval(doc, vals, f, st),
| FeatureKind::SurfaceBlend { .. }
| FeatureKind::SurfaceOffset { .. }
| FeatureKind::SurfaceRuled { .. }
| FeatureKind::SurfaceUntrim { .. } => surface::eval(doc, vals, f, st, warning),
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
@@ -3950,6 +4147,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
};
let mut sk = sketch.clone();
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
// ones its expressions give come first.
doc.apply_text_values(vals, &mut sk)?;
let mut warns = match anchor {
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
@@ -3986,7 +4186,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
// Cuts through sheet metal become cut-outs of its flat pattern.
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref(), warning)?;
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
}
@@ -3996,7 +4196,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
FeatureKind::Hole { thread, diameter, .. } => {
let (spots, dir) = hole_spots(st, f)?;
let tools = hole_feature_tools(vals, st, f)?;
let handled = sheet_hole_cut(st, f, &tools)?;
let handled = sheet_hole_cut(st, f, &tools, warning)?;
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
if handled.is_empty() {
apply_traced(st, f, tools, Operation::Cut, &[])?;
@@ -4386,7 +4586,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false)?;
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false, warning)?;
let tools = vec![crate::lineage::box_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
@@ -4398,7 +4598,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let b = kernel::cylinder(base, *axis, radius, height)?;
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false)?;
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false, warning)?;
let tools = vec![crate::lineage::cylinder_tool(b)];
if !handled.is_empty() {
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
@@ -4550,15 +4750,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Import { step, .. } => {
FeatureKind::Import { step, skip, .. } => {
let imp = kernel::step_import_shared(step)?;
let digest = crate::config::payload_digest(step);
// Files from board and assembly tools give thousands of bodies the same name: name
// them from a set, not by searching the bodies for every candidate (cubic).
let mut names = BodyNames::new(st);
for (k, b) in imp.bodies.iter().enumerate() {
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
let name = names.unique(base);
let names = import_body_names(st, &f.body_names, &imp, skip);
for (b, name) in imp.bodies.iter().zip(names) {
let Some(name) = name else { continue };
let b = crate::lineage::numbered_tool(b.body.clone().with_import_certificate(&digest, f.id)?);
st.bodies.push(ModelBody::traced_new(name, b, f.id));
}
@@ -4591,6 +4788,27 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
Ok(out)
}
/// The names an Import feature gives the file's bodies, added after the bodies of `st`, in the
/// file's order: `None` for a body it skips (a later instance of a repeated part). `body_names`
/// are the feature's stored names (by index), else the file's own.
pub fn import_body_names(st: &ModelState, body_names: &[String], imp: &kernel::StepImport, skip: &[u64]) -> Vec<Option<String>> {
let skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
// Files from board and assembly tools give thousands of bodies the same name: name them
// from a set, not by searching the bodies for every candidate (cubic).
let mut names = BodyNames::new(st);
imp.bodies
.iter()
.enumerate()
.map(|(k, b)| {
if skip.contains(&(k as u64)) {
return None;
}
let base = body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
Some(names.unique(base))
})
.collect()
}
/// [`unique_body_name`] for many bodies in a row: the same names, in time linear in the bodies.
struct BodyNames {
taken: std::collections::HashSet<String>,
+291 -77
View File
@@ -157,16 +157,25 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
}
/// Unit outward normal of the planar face through `p`.
/// Unit outward normal of the planar face through `p`: of the planar faces whose plane runs
/// through it, the one nearest it. A face elsewhere in such a plane (the wall of a notch, the end
/// of a rib, in line with a rim) is no pick of `p`, though its plane runs through it as exactly.
fn planar_normal_at(b: &Body, p: Vec3) -> Option<Vec3> {
let tol = (b.size() * 2e-3).max(1e-3);
let faces = b.faces(tol).ok()?;
let mesh = b.tessellate(tol).ok()?;
let mut near = vec![f64::INFINITY; faces.len()];
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
if let (Some([u, v, w]), Some(d)) = (mesh.tri(t), near.get_mut(*f as usize)) {
*d = d.min(crate::appearance::closest_on_triangle(p, u, v, w).dist(p));
}
}
faces
.iter()
.filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs())))
.filter(|(_, d)| *d < tol * 5.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(n, _)| n)
.filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs(), near.get(f.index).copied().unwrap_or(f64::INFINITY))))
.filter(|(_, d, _)| *d < tol * 5.0)
.min_by(|a, b| a.2.total_cmp(&b.2).then(a.1.total_cmp(&b.1)))
.map(|(n, _, _)| n)
}
/// The curves of a chain as one polyline in sketch coordinates, in order.
@@ -609,7 +618,10 @@ pub(super) fn sheet_eval(
None => *p,
});
}
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
for note in notes {
append_warning(warning, note);
}
tag_flanges(&mut sh, 0, f.id);
put_sheet(st, sh)
}
@@ -637,16 +649,18 @@ pub(super) fn sheet_eval(
// along a round wall an arc of the wall's circle (not chords: the plate check below
// compares its area with the exact volume).
let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
// Recesses in one face (an engraved label) become pockets; the outline is the other face's.
let recesses = plate_recesses(&mb.body, &mesh, &plane, *face, n, (lo, hi), tol)?;
let mut walls = std::collections::HashMap::new();
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| round_wall(&mb.body, &plane, n, f));
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f));
let mut loops: Vec<Loop2> =
face_outline(&mesh, &plane, *face, n, lo, tol).iter().map(|l| outline_loop(l, &mut wall, tol * 1e-2)).collect();
face_outline(&mesh, &plane, *face, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
let mut it = loops.into_iter();
let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw();
let region = Region2 { outer, holes: it.map(|l| l.ccw().reversed()).collect() };
let area = region.area().abs();
let vol = kernel::measure(&mb.body)?.volume;
let vol = kernel::measure(&mb.body)?.volume + recesses.volume;
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
return Err(DocError::Invalid(
"not supported yet: converting bodies that are not a plate of even thickness without bend edges (`bends`)".into(),
@@ -657,6 +671,10 @@ pub(super) fn sheet_eval(
rv.thickness = t;
let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() });
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
let mut work = sheet::FlatWork::new();
for (pocket, z0, z1) in recesses.pockets {
sh.record_pocket(pocket, z0, z1, &mut work)?;
}
put_sheet(st, sh)
}
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
@@ -720,7 +738,11 @@ fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option
}
// Bound floating point projection separately from model geometry tolerance.
let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0);
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff {
// The cap's current point lies off the recorded plane by the noise of evaluating
// its edges, which its fitted tilt shows across the distance between the two points
// (8e-12 over 2.4 mm on a 45° flange's drill, #1255; none on axis-aligned caps).
let noise = current.normal().cross(n).len() * (current.origin - plane.origin).len();
if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff + noise {
return None;
}
projection_roundoff = projection_roundoff.max(roundoff);
@@ -742,6 +764,7 @@ pub(super) fn sheet_cut(
tools: &[Body],
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
warning: &mut Option<String>,
) -> Result<Vec<String>> {
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
@@ -749,7 +772,16 @@ pub(super) fn sheet_cut(
}
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some())
// The outlines of the sketch's text: the sheet remembers where its letters went.
let text: Vec<Seg2> = ss
.sketch
.links
.iter()
.filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. }))
.flat_map(|l| ss.sketch.link_curves(&l.id))
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect();
sheet_prism_cut(f, st, &ss.plane, &regions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning)
}
/// Whether a cut of `f` with these targets (else its participants, else its component's
@@ -781,6 +813,7 @@ pub(super) fn sheet_primitive_cut(
prism: PrimitivePrism,
operation: Operation,
drill: bool,
warning: &mut Option<String>,
) -> Result<Vec<String>> {
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
return Ok(Vec::new());
@@ -805,7 +838,7 @@ pub(super) fn sheet_primitive_cut(
let Some(normal) = normal else { return Ok(Vec::new()) };
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
let tools = kernel::extrude(&plane, std::slice::from_ref(&region), interval.0, interval.1)?;
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false)
sheet_prism_cut(f, st, &plane, std::slice::from_ref(&region), operation, &tools, &[], None, false, &[], warning)
}
/// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each
@@ -892,6 +925,12 @@ impl PrimitivePrism {
}
}
/// The most outline segments of a sketch's text a cut tells letters by, and the most text regions
/// a sheet keeps (see [`crate::sheet::SheetBody::labels`]).
const MAX_TEXT_CURVES: usize = 20_000;
const MAX_LABELS: usize = 1000;
/// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut).
#[allow(clippy::too_many_arguments)]
fn sheet_prism_cut(
f: &Feature,
@@ -903,6 +942,8 @@ fn sheet_prism_cut(
targets: &[String],
constructor: Option<&ExtrudeConstructor>,
footprints_bound: bool,
text: &[Seg2],
warning: &mut Option<String>,
) -> Result<Vec<String>> {
let n = plane.normal();
let mut work = sheet::FlatWork::new();
@@ -941,7 +982,14 @@ fn sheet_prism_cut(
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
.then_some(c.interval)
});
// The regions of the cut that are letters, with their counters.
let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES {
regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect()
} else {
Vec::new()
};
let mut plans = Vec::new();
let mut notes = Vec::new();
'sheets: for original in &st.sheets {
if !sheet_eligible(f, st, targets, original) {
continue;
@@ -974,6 +1022,7 @@ fn sheet_prism_cut(
sh.material_panels(Some(plane), &mut work)?
};
let mut modeled = false;
let mut labels = Vec::new();
for (pi, panel) in candidates {
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
@@ -1013,6 +1062,7 @@ fn sheet_prism_cut(
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
// Sub-resolution layers must not be silently erased by endpoint snapping.
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok()));
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
} else {
@@ -1077,19 +1127,34 @@ fn sheet_prism_cut(
}
}
}
if modeled {
for flat in labels {
if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS {
sh.labels.push(flat);
}
}
}
if !modeled {
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
} else if sh.flat_material()?.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
// topology errors propagate; they are never mistaken for an empty blank.
plans.push((sh.body.clone(), None));
} else {
let body = sh.solid()?;
plans.push((sh.body.clone(), Some((sh, body))));
let material = sh.flat_material()?;
if material.is_empty() {
// Empty exact material removes geometry and metadata together. Budget or
// topology errors propagate; they are never mistaken for an empty blank.
plans.push((sh.body.clone(), None));
} else {
// Every piece the cut leaves stays; the ones it parts from the base are named.
notes.extend(parted_note(&sh, &material, original));
let body = sh.solid()?;
plans.push((sh.body.clone(), Some((sh, body))));
}
}
}
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
if !notes.is_empty() {
append_warning(warning, notes.join("; "));
}
for (name, result) in plans {
match result {
Some((sh, body)) => {
@@ -1110,6 +1175,35 @@ fn sheet_prism_cut(
Ok(handled)
}
/// A cut's note on the pieces it parted from the base of `sh` (see
/// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut,
/// `before` the sheet before it): how many, their area and where they lie in the flat pattern.
/// The pieces stay, as every piece a cut leaves does. None when nothing is parted, and when the
/// pieces can't be worked out: the note is no reason for a cut to fail.
fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::SheetBody) -> Option<String> {
let pieces = sh.parted_from_base(material, before).ok().filter(|p| !p.is_empty())?;
let area: f64 = pieces.iter().map(Region2::area).sum();
let at: Vec<String> = pieces
.iter()
.take(4)
.map(|r| {
let c = r.interior_point();
format!("({:.1}, {:.1})", c.x, c.y)
})
.collect();
let at = at.join(", ");
let body = &sh.body;
Some(match pieces.len() {
1 => format!("1 piece of `{body}` is no longer joined to its base ({area:.2} mm², near {at} in the flat pattern); it is kept"),
n => {
let more = if n > 4 { format!(" and {} more", n - 4) } else { String::new() };
format!(
"{n} pieces of `{body}` are no longer joined to its base ({area:.2} mm² in all, near {at}{more} in the flat pattern); they are kept"
)
}
})
}
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
/// `level` from `at` along `n`: the edges used by one of them only, chained.
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
@@ -1121,18 +1215,8 @@ fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, le
fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<(Vec2, Option<u32>)>> {
let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol));
let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect();
// Boundary edges by position (meshes may repeat vertices per triangle).
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
let key = |a: Vec3, b: Vec3| {
let (ka, kb) = (q(a), q(b));
(ka.min(kb), ka.max(kb))
};
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
for (a, b) in &edges {
*count.entry(key(*a, *b)).or_insert(0) += 1;
}
// The faces off the plane at each point on it, by position to a millionth of a millimetre
// either way (a neighbouring face's mesh may round a shared point differently).
let mut touch: std::collections::HashMap<(i64, i64, i64), Vec<u32>> = Default::default();
@@ -1156,6 +1240,34 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3,
let at_b = near(b);
near(a).into_iter().find(|f| at_b.contains(f))
};
chain_loops(mesh, plane, &on, across)
}
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh.
fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec<Vec<Vec2>> {
chain_loops(mesh, plane, on, |_, _| None).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect()
}
/// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh, each point with
/// `across(it, next)`: the face the outline runs along from it to the next point.
fn chain_loops(
mesh: &solidcraft_geom::Mesh,
plane: &Plane,
on: &[[u32; 3]],
across: impl Fn(Vec3, Vec3) -> Option<u32>,
) -> Vec<Vec<(Vec2, Option<u32>)>> {
// Boundary edges by position (meshes may repeat vertices per triangle).
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
let key = |a: Vec3, b: Vec3| {
let (ka, kb) = (q(a), q(b));
(ka.min(kb), ka.max(kb))
};
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
for (a, b) in &edges {
*count.entry(key(*a, *b)).or_insert(0) += 1;
}
let mut boundary: Vec<(Vec3, Vec3)> = edges.into_iter().filter(|(a, b)| count.get(&key(*a, *b)) == Some(&1)).collect();
// Chain into loops (plane coordinates).
let mut loops: Vec<Vec<(Vec2, Option<u32>)>> = Vec::new();
@@ -1194,60 +1306,162 @@ fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3,
loops
}
/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall
/// standing square on the plane: a cylinder about the plane's normal `n`.
fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> {
match kernel::face_surface(body, f as usize)? {
kernel::FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)),
_ => None,
}
/// What the recesses of a plate (an engraved label, a counterbore) take out of it, for Convert
/// to Sheet Metal: each becomes a pocket of the sheet, from its floor to the face it opens on.
struct Recesses {
/// The level (along the picked face's normal) of the plate's face without recesses, whose
/// outline the sheet takes.
level: f64,
/// Pockets in flat coordinates, with their depth interval in the sheet (0 to its thickness).
pockets: Vec<(Region2, f64, f64)>,
/// The material they take out.
volume: f64,
}
/// A loop of [`face_outline`] as segments: each run along one round wall (`wall` gives a face's
/// circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of one radius are
/// one wall, however many faces it comes in (many systems write a round hole's wall as two
/// half-cylinders), so such a hole is one circle. `eps`: how far off its circle a point may be.
fn outline_loop(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 {
use std::f64::consts::{PI, TAU};
let m = pts.len();
let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default();
// The circle each segment runs along: its wall's, when both its ends lie on it.
let circles: Vec<Option<(Vec2, f64)>> = (0..m)
.map(|i| {
let (c, r) = wall(pts.get(i)?.1?)?;
((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r))
})
.collect();
let circle = |i: usize| circles.get(i % m).copied().flatten();
let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps);
// Start where no arc runs on across the start (anywhere when the whole loop is one circle).
let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0);
let mut segs = Vec::new();
let mut i = 0;
while i < m {
let k = start + i;
let Some((c, r)) = circle(k) else {
segs.push(Seg2::Line { a: point(k), b: point(k + 1) });
i += 1;
/// A planar face of the mesh parallel to the plate: its lowest and highest level, whether its
/// outward normal is the picked face's (it faces up), its area, a point on it, and its
/// triangles' centres and areas.
struct LevelFace {
lo: f64,
hi: f64,
up: bool,
area: f64,
at: Vec3,
tris: Vec<(Vec3, f64)>,
}
/// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`),
/// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its
/// thickness, lying in an opening of the face it is cut into, and its walls are square to the
/// plate (walls that are not show as material the plate's volume doesn't account for). A body
/// that is no plate once its recesses are filled has none, so the plate check refuses it as
/// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face
/// only, up to half the thickness deep, and no larger than the face they are cut into (a plate
/// raised in places is no plate of the raised thickness).
fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (lo, hi): (f64, f64), tol: f64) -> Result<Recesses> {
let t = hi - lo;
let eps = t * 1e-3;
let level = |p: Vec3| (p - at).dot(n);
let mut faces: BTreeMap<u32, Option<LevelFace>> = BTreeMap::new();
for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
let Some([a, b, c]) = mesh.tri(tri) else { continue };
let cr = (b - a).cross(c - a);
let len = cr.len();
if !(len > 1e-12) {
continue;
};
let mut j = i + 1;
while j < m && same(start + j, (c, r)) {
j += 1;
}
let sweep: f64 = (i..j)
.map(|s| {
let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle();
(d + PI).rem_euclid(TAU) - PI
})
.sum();
// Arcs of up to half a turn each.
let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4);
let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64);
segs.extend((0..pieces).map(|p| Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step }));
i = j;
let up = cr.dot(n) > 0.0;
let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() }));
match entry {
Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => {
for p in [a, b, c] {
face.lo = face.lo.min(level(p));
face.hi = face.hi.max(level(p));
}
face.area += len / 2.0;
face.tris.push(((a + b + c) / 3.0, len / 2.0));
}
_ => *entry = None,
}
}
Loop2 { segs }
// The floors (face index, level, the face), and the areas of the plate's two faces (at `lo`
// and at `hi`).
let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new();
let (mut bottom, mut top) = (0.0, 0.0);
for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) {
let d = (face.lo + face.hi) / 2.0;
if face.hi - face.lo > eps {
continue;
} else if (d - hi).abs() <= eps && face.up {
top += face.area;
} else if (d - lo).abs() <= eps && !face.up {
bottom += face.area;
} else if d > lo + eps && d < hi - eps {
floors.push((f, d, face));
}
}
let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 };
if floors.is_empty() {
return Ok(none());
}
// A floor is a recess's where (most of) it lies in an opening of the face it is cut into:
// inside that face's outline and in a hole of it, not on an island in the hole. (Its
// triangles are those at its level within `eps`: `tol` may exceed a recess's depth.)
let loops_at = |level: f64| -> Vec<Loop2> { face_loops(mesh, plane, at, n, level, eps).into_iter().map(|l| Loop2::polygon(&l)).collect() };
let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo));
floors.retain(|(_, _, face)| {
let loops = if face.up { &top_loops } else { &bottom_loops };
let open = |p: Vec2| {
let k = loops.iter().filter(|l| l.contains(p)).count();
k >= 2 && k.is_multiple_of(2)
};
face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::<f64>() > face.area / 2.0
});
let Some(&(_, _, first)) = floors.first() else { return Ok(none()) };
// Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their
// openings count, and its volume with theirs the outline times the thickness. Else (a bent
// part) they are none.
let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo };
let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::<f64>();
let outline = bottom + opened(false);
let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum();
let volume = kernel::measure(body)?.volume;
if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) {
return Ok(none());
}
let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z);
let up = first.up;
if let Some(other) = floors.iter().find(|f| f.2.up != up) {
return Err(DocError::Invalid(format!(
"not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets",
pt(first.at),
pt(other.2.at)
)));
}
let depth_of = |d: f64| depth_in(d, up);
if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) {
return Err(DocError::Invalid(format!(
"not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets",
pt(deep.2.at),
depth_of(deep.1),
t
)));
}
let face_area = if up { top } else { bottom };
if floors.iter().map(|f| f.2.area).sum::<f64>() > face_area {
return Err(DocError::Invalid(format!(
"not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)"
)));
}
// The floors' outlines, from a mesh fine enough to mark them by.
let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?;
floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1)));
// Floors at one depth are one pocket where they meet.
let mut groups: Vec<(f64, Vec<Region2>)> = Vec::new();
for (f, d, ..) in &floors {
let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect();
let mut loops = boundary_loops(&fine, plane, &on);
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
let mut it = loops.into_iter();
let Some(outline) = it.next() else { continue };
let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
match groups.last_mut() {
Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region),
_ => groups.push((depth_of(*d), vec![region])),
}
}
// The sheet's z starts from the picked face when its plane faces away from that face.
let from_zero = up == (plane.normal().dot(n) < 0.0);
let mut work = sheet::FlatWork::new();
let (mut pockets, mut volume) = (Vec::new(), 0.0);
for (depth, regions) in groups {
let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) };
for region in sheet::material_union(regions, &mut work)? {
volume += region.area().abs() * depth;
pockets.push((region, z0, z1));
}
}
Ok(Recesses { level: if up { lo } else { hi }, pockets, volume })
}
/// The body a placed plastic feature joins: by name, else the one under the point.
+101 -11
View File
@@ -1,13 +1,14 @@
//! Evaluation of the surface features: Patch, Stitch, Thicken, Trim, Extend, Surface Extrude,
//! Revolve, Sweep and Loft, Offset, Unstitch and Reverse Normal (kernel `surfaces`,
//! `surface_offset`).
//! Revolve, Sweep and Loft, Offset, Ruled, Untrim, Unstitch and Reverse Normal (kernel `surfaces`,
//! `surface_offset`, `surface_ruled`, `surface_untrim`).
use super::*;
use crate::{RuledCorners, RuledMode};
use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges } => {
FeatureKind::Patch { sketch, profiles, body, edges, crown } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
@@ -19,7 +20,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if !edges.is_empty() {
let i = body_at(st, body, edges)?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
made.push(kernel::patch_edges(&b.body, edges)?);
made.push(kernel::patch_edges_crowned(&b.body, edges, *crown)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
@@ -77,16 +78,55 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceExtend { body, edges, distance } => {
let d = val(vals, distance, Kind::Length)?;
FeatureKind::SurfaceExtend { body, edges, distance, mode, up_to, neighbours } => {
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let out = kernel::extend(&mb.body, edges, d)?;
let mode = match mode {
crate::ExtendMode::Natural => kernel::ExtendMode::Natural,
crate::ExtendMode::Tangent => kernel::ExtendMode::Tangent,
crate::ExtendMode::Curvature => kernel::ExtendMode::Curvature,
};
let at = |limit| kernel::ExtendOptions { mode, limit, neighbours: *neighbours };
let out = match up_to {
None => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Distance(val(vals, distance, Kind::Length)?)))?,
Some(crate::ExtendTo::Bounds) => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Bounds))?,
Some(crate::ExtendTo::Plane(plane)) => {
let tool = kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?);
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
}
Some(crate::ExtendTo::Face(t)) => {
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
let face = crate::appearance::face_index_at(tb, t.point)
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the target point", t.body)))?;
let tool = kernel::SplitTool::Face { body: &tb.body, face };
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
}
};
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, out, mb.feature);
}
Ok(())
}
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch } => {
let a = st.body(&from.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", from.body)))?.body.clone();
let b = st.body(&to.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", to.body)))?.body.clone();
if from.edges.is_empty() || to.edges.is_empty() {
return Err(DocError::Invalid("a blend needs a chain of edges at each end".into()));
}
let (made, gap) = kernel::blend_between(&a, &from.edges, &b, &to.edges, *continuity, *bulge, *align, BLEND_STATIONS, *stitch)?;
if gap > 1e-9 {
*warning = Some(format!("the blend's boundary lies up to {gap:.4} mm from the edges it was built on"));
}
let name = new_name(st, f, 0);
// A stitched blend takes the place of both bodies it joins; otherwise it is a new
// surface body of its own.
if *stitch {
let gone = [from.body.clone(), to.body.clone()];
st.bodies.retain(|x| !gone.contains(&x.name));
}
st.bodies.push(ModelBody::new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
let ss = sketch_in_feature_frame(
doc,
@@ -156,7 +196,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => {
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
@@ -175,8 +216,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
let body = match path_points(ps, path, start)? {
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?,
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?,
Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?,
None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
};
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
push_sheet(st, f, traced);
@@ -234,6 +275,52 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
st.bodies.push(ModelBody::traced_new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceRuled { body, edges, distance, mode, angle, direction, flip, corners, face } => {
let d = val(vals, distance, Kind::Length)?;
let mode = match mode {
RuledMode::Normal => kernel::RuledMode::Normal,
RuledMode::Tangent => kernel::RuledMode::Tangent,
RuledMode::Angle => {
let a = angle.as_ref().ok_or_else(|| DocError::Invalid("a ruled surface at an angle needs `angle`".into()))?;
kernel::RuledMode::Angle { angle: val(vals, a, Kind::Angle)?, reference: *direction }
}
RuledMode::Direction => kernel::RuledMode::Direction(
direction.ok_or_else(|| DocError::Invalid("a ruled surface along a direction needs `direction`".into()))?,
),
};
let corners = match corners {
RuledCorners::Mitre => kernel::RuledCorners::Mitre,
RuledCorners::Round => kernel::RuledCorners::Round,
};
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
let r = kernel::Ruled { distance: d, mode, flip: *flip, corners, face: *face };
let made = Traced::step(kernel::ruled_surface(&mb.body, edges, &r)?, &[&mb.traced()]);
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::traced_new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => {
let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", face.body)))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let fi = crate::appearance::face_index_at(&mb, face.point)
.ok_or_else(|| DocError::Invalid(format!("Untrim: no face of {} at {:?}", mb.name, face.point)))?;
let which = match holes {
None => kernel::UntrimHoles::All,
Some(points) => kernel::UntrimHoles::At(points),
};
// A surface body's face is untrimmed where it is when asked; a solid's is copied out.
let replace = *in_place && mb.body.is_surface();
let made = mb.traced().then(kernel::untrim(&mb.body, fi, which, *outer, replace)?);
if replace {
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::traced_new(mb.name, made, mb.feature);
}
} else {
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::traced_new(name, made, f.id));
}
Ok(())
}
_ => Err(DocError::Invalid("not a surface feature".into())),
}
}
@@ -262,6 +349,9 @@ fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[Stri
/// Most sketch curves one feature chains.
const MAX_CHAIN_CURVES: usize = 1000;
/// Stations a blend samples each of its two boundaries at: enough that the boundary it fits is
/// within a small part of a thousandth of the chain's length of the edges it was built on.
const BLEND_STATIONS: usize = 41;
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
+38 -1
View File
@@ -237,6 +237,8 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len());
let mut i = 0;
let mut out = Vec::new();
// Parentheses open at this point: a comma outside them can only be a decimal comma.
let mut depth = 0usize;
while let Some(&(_, c)) = cs.get(i) {
if c.is_whitespace() {
i += 1;
@@ -245,6 +247,17 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
i += 1;
}
// A decimal comma (`2,5 mm`, as typed with a numeric keypad in many locales, #1347):
// a comma between digits, in a number with no point, outside any parentheses (inside
// them a comma separates a function's arguments: `max(1,2)`).
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
if depth == 0 && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
i += 1;
while digit(i) {
i += 1;
}
}
// Exponent: 1e-3 (but not `2em`).
if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') {
let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+');
@@ -257,7 +270,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
}
}
let t = s.get(at(st)..at(i)).unwrap_or_default();
let v: f64 = t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
let v: f64 = t.replace(',', ".").parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
out.push((Tok::Num(v), at(st), at(i)));
} else if c.is_alphabetic() || c == '_' || c == 'µ' {
let st = i;
@@ -282,6 +295,11 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
out.push((Tok::Op(','), at(i), at(i + 1)));
i += 1;
} else if "+-*/^(),%".contains(c) {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
_ => {}
}
out.push((Tok::Op(c), at(i), at(i + 1)));
i += 1;
} else {
@@ -819,6 +837,25 @@ mod tests {
ParamDef { name: n.into(), expr: e.into(), unit: u.into() }
}
#[test]
fn a_decimal_comma_outside_parentheses() {
// #1347: a comma between digits is a decimal comma where it cannot separate arguments.
assert_eq!(ev("2,5", Kind::Unitless).unwrap(), 2.5);
assert_eq!(ev("2,5 mm", Kind::Length).unwrap(), 2.5);
assert_eq!(ev("12,75mm + 0,25", Kind::Length).unwrap(), 13.0);
assert_eq!(ev("width / 2,5", Kind::Length).unwrap(), 16.0);
assert!((ev("1,5e3 mm", Kind::Length).unwrap() - 1500.0).abs() < 1e-9);
assert!((ev("22,5 deg", Kind::Angle).unwrap() - 22.5f64.to_radians()).abs() < 1e-12);
// Inside parentheses a comma still separates a function's arguments.
assert_eq!(ev("max(1,2)", Kind::Unitless).unwrap(), 2.0);
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
// Not between two digits, or a second comma: still an error.
for bad in ["2,", ",5", "2 ,5", "2, 5", "1,5,3", "1.5,3"] {
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
}
}
#[test]
fn arithmetic_and_units() {
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
+4 -284
View File
@@ -1,14 +1,13 @@
//! The geometry a feature holds, each piece with its role: the points that pick faces (moved
//! with the feature, and the source of its persistent face names), other positions, directions,
//! planes, construction references, a primitive's placement point and its profile selections.
//! One walk, written once for `&` and `&mut`, behind `transform_geometry`, `face_points`,
//! `profile_sels` and `point_expr`. Every variant is listed, so a new one has to say what each of
//! its points is.
//! The walk (`crate::walk`) is behind `transform_geometry`, `face_points`, `profile_sels` and
//! `point_expr`.
use solidcraft_geom::Vec3;
use crate::construct::GeoRef;
use crate::{AxisRef, FeatureKind, PatternKind, PlaneRef, PointExpr, ProfileSel};
use crate::walk::Walk;
use crate::{FeatureKind, PointExpr, ProfileSel};
/// A piece of a feature's geometry: `V` is `&Vec3` or `&mut Vec3`, and so on for the others.
pub(crate) enum Geo<V, P, G, E, S> {
@@ -30,286 +29,7 @@ pub(crate) enum Geo<V, P, G, E, S> {
Profiles(u64, S),
}
pub(crate) type GeoItem<'a> = Geo<&'a Vec3, &'a PlaneRef, &'a GeoRef, &'a PointExpr, &'a ProfileSel>;
pub(crate) type GeoItemMut<'a> = Geo<&'a mut Vec3, &'a mut PlaneRef, &'a mut GeoRef, &'a mut PointExpr, &'a mut ProfileSel>;
/// Every piece of a feature's geometry, written once for `&` and `&mut`: `$item` is `GeoItem` or
/// `GeoItemMut`, `$refs` the matching `refs` / `refs_mut`, `$m` is `mut` or nothing. Face picks
/// come in the order the feature's face names follow.
macro_rules! geometry_walk {
($name:ident, $item:ident, $refs:ident $(, $m:tt)?) => {
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut($item<'a>)) {
use Geo::{Construct, Dir, Pick, Pinned, Placement, Plane, Pos, Profiles};
match self {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => f(Plane(plane)),
FeatureKind::ConstructionAxis { def } => {
for r in def.$refs() {
f(Construct(r));
}
}
FeatureKind::ConstructionPoint { def } => {
for r in def.$refs() {
f(Construct(r));
}
}
FeatureKind::Extrude { sketch, profiles, extent, thin, .. } => {
// A thin extrude follows curves; its profiles are not used.
if thin.is_none() {
f(Profiles(*sketch, profiles));
}
if let Some(p) = & $($m)? extent.to {
f(Pick(p));
}
if let Some(p) = & $($m)? extent.from {
f(Pick(p));
}
}
// Surface Extrude refuses `to` and `from` for now; they would pick faces as for
// Extrude.
FeatureKind::SurfaceExtrude { sketch, profiles, extent, .. } => {
if let Some(s) = profiles {
f(Profiles(*sketch, s));
}
if let Some(p) = & $($m)? extent.to {
f(Pick(p));
}
if let Some(p) = & $($m)? extent.from {
f(Pick(p));
}
}
FeatureKind::Revolve { sketch, profiles, axis, to, .. } => {
f(Profiles(*sketch, profiles));
if let AxisRef::Line { origin, dir } = axis {
f(Pos(origin));
f(Dir(dir));
}
if let Some(p) = to {
f(Pick(p));
}
}
FeatureKind::SurfaceRevolve { sketch, profiles, axis, .. } => {
if let Some(s) = profiles {
f(Profiles(*sketch, s));
}
if let AxisRef::Line { origin, dir } = axis {
f(Pos(origin));
f(Dir(dir));
}
}
FeatureKind::Sweep { sketch, profiles, .. } | FeatureKind::SheetBase { sketch, profiles, .. } => f(Profiles(*sketch, profiles)),
FeatureKind::SurfaceSweep { sketch, profiles, .. } => {
if let Some(s) = profiles {
f(Profiles(*sketch, s));
}
}
FeatureKind::Emboss { sketch, profiles, face, .. } => {
f(Profiles(*sketch, profiles));
if let Some(face) = face {
f(Pinned(& $($m)? face.at));
}
}
FeatureKind::Loft { sections, .. } => {
// A section that is a point has no profile.
for s in sections {
if s.point.is_none() {
f(Profiles(s.sketch, & $($m)? s.profiles));
}
}
}
FeatureKind::SurfaceLoft { sections } => {
for s in sections {
if let Some(p) = & $($m)? s.profiles {
f(Profiles(s.sketch, p));
}
}
}
FeatureKind::Patch { sketch, profiles, edges, .. } => {
if let Some(sketch) = sketch {
f(Profiles(*sketch, profiles));
}
for e in edges {
f(Pos(e));
}
}
FeatureKind::Fillet { edges, style, .. } => {
for e in edges {
f(Pos(e));
}
if let crate::FilletStyle::Variable { start, .. } | crate::FilletStyle::Spline { start, .. } = style {
f(Pos(start));
}
}
FeatureKind::Chamfer { edges, .. } | FeatureKind::SheetHem { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => {
for e in edges {
f(Pos(e));
}
}
FeatureKind::Shell { faces, .. }
| FeatureKind::OffsetFace { faces, .. }
| FeatureKind::DeleteFace { faces, .. }
| FeatureKind::SurfaceOffset { faces, .. } => {
for p in faces {
f(Pick(p));
}
}
FeatureKind::Unstitch { faces, .. } | FeatureKind::ReverseNormal { faces, .. } => {
for p in faces {
f(Pos(p));
}
}
FeatureKind::BoundaryFill { cells, .. } => {
for p in cells {
f(Pos(p));
}
}
FeatureKind::Draft { faces, neutral, pull, .. } => {
for p in faces {
f(Pick(p));
}
f(Plane(neutral));
f(Dir(pull));
}
FeatureKind::ReplaceFace { faces, target, .. } => {
for p in faces {
f(Pick(p));
}
f(Plane(target));
}
FeatureKind::SplitFace { faces, plane, tool, .. } => {
for p in faces {
f(Pick(p));
}
f(Plane(plane));
if let Some(t) = tool {
f(Pos(& $($m)? t.point));
}
}
FeatureKind::Split { plane, tool, .. } => {
f(Plane(plane));
if let Some(t) = tool {
f(Pos(& $($m)? t.point));
}
}
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
f(Plane(plane));
if let Some(t) = tool {
f(Pos(& $($m)? t.point));
}
f(Pos(keep));
}
FeatureKind::Box { corner: p, .. } | FeatureKind::Sphere { center: p, .. } | FeatureKind::Torus { center: p, .. } => {
f(Placement(p))
}
FeatureKind::Cylinder { base, axis, .. } | FeatureKind::Coil { base, axis, .. } => {
f(Placement(base));
f(Dir(axis));
}
FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { dir1, dir2, .. } => {
f(Dir(dir1));
if let Some(d) = dir2 {
f(Dir(d));
}
}
PatternKind::Circular { origin, axis, .. } => {
f(Pos(origin));
f(Dir(axis));
}
// Follows its path sketch.
PatternKind::Path { .. } => {}
},
FeatureKind::Hole { position, direction, points, to, .. } => {
// Placed at sketch points, the hole names no face by its position.
if points.is_none() {
f(Pick(position));
} else {
f(Pos(position));
}
f(Dir(direction));
if let Some(p) = to {
f(Pick(p));
}
}
FeatureKind::Thread { face, .. } => f(Pick(face)),
FeatureKind::SheetFlange { edges, .. } => {
for e in edges {
f(Pos(& $($m)? e.point));
}
}
FeatureKind::SheetFold { a, b, fixed, .. } => {
f(Pos(a));
f(Pos(b));
if let Some(p) = fixed {
f(Pos(p));
}
}
FeatureKind::SheetConvert { face, bends, .. } => {
// With bend edges the fixed face is found by name (a plate keeps its point).
if bends.is_empty() {
f(Pos(face));
} else {
f(Pick(face));
}
for p in bends {
f(Pos(p));
}
}
FeatureKind::Boss { position, direction, .. } => {
f(Pick(position));
f(Dir(direction));
}
FeatureKind::SnapFit { position, direction, hook, .. } => {
f(Pick(position));
f(Dir(direction));
f(Dir(hook));
}
FeatureKind::Rest { position, direction, along, .. } => {
f(Pick(position));
f(Dir(direction));
if let Some(a) = along {
f(Dir(a));
}
}
FeatureKind::Lip { face, .. } => f(Pos(face)),
FeatureKind::Align { from, to, from_normal, to_normal, .. } => {
f(Pos(from));
f(Pos(to));
if let Some(n) = from_normal {
f(Dir(n));
}
if let Some(n) = to_normal {
f(Dir(n));
}
}
FeatureKind::Scale { origin, .. } => f(Pos(origin)),
FeatureKind::Move { rotate_axis, .. } => {
if let Some(a) = rotate_axis {
f(Dir(a));
}
}
FeatureKind::SheetContour { .. }
| FeatureKind::SheetPerforate { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::Combine { .. }
| FeatureKind::BoundingSolid { .. }
| FeatureKind::Pipe { .. }
| FeatureKind::Rib { .. }
| FeatureKind::Remove { .. }
| FeatureKind::Stitch { .. }
| FeatureKind::Thicken { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. }
| FeatureKind::MeshConvert { .. }
| FeatureKind::BodyTransfer { .. } => {}
}
}
};
}
impl FeatureKind {
geometry_walk!(walk_geometry, GeoItem, refs);
geometry_walk!(walk_geometry_mut, GeoItemMut, refs_mut, mut);
/// The points that pick faces (shell, draft, offset and replace faces, a hole's placement and
/// `to`, an extrude's `to` and `from`…), in the order of the feature's persistent face names.
pub fn face_points_mut(&mut self) -> Vec<&mut Vec3> {
+4 -216
View File
@@ -1,12 +1,12 @@
//! References from a feature to the rest of the design: the sketches it is built from (by id),
//! the features it names (patterned and mirrored features, the construction plane a plane is
//! built on) and the bodies it works on (by name). One walk, written once for `&` and `&mut`,
//! behind `input_sketches`, `remap_refs` and `remap_bodies`. Every variant is listed, so a new
//! one has to say what it refers to.
//! built on) and the bodies it works on (by name). The walk (`crate::walk`) is behind
//! `input_sketches`, `remap_refs` and `remap_bodies`.
use std::collections::BTreeMap;
use crate::{FeatureKind, PatternKind, PlaneRef};
use crate::FeatureKind;
use crate::walk::Walk;
/// A reference a feature holds: `S` is `&u64` or `&mut u64` (a sketch id), `N` is `&String` or
/// `&mut String` (a feature or body name).
@@ -16,219 +16,7 @@ pub(crate) enum Ref<S, N> {
Body(N),
}
/// The construction plane a plane is built on (through offsets and angles), for `&` and `&mut`
/// (`$m` is `mut` or nothing). Nesting is capped like every walk over a plane.
macro_rules! plane_walk {
($name:ident $(, $m:tt)?) => {
fn $name<'a>(mut p: &'a $($m)? PlaneRef, f: &mut impl FnMut(Ref<&'a $($m)? u64, &'a $($m)? String>)) {
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
_ => return,
}
}
}
};
}
plane_walk!(plane_refs);
plane_walk!(plane_refs_mut, mut);
/// Every reference of a feature, written once for `&` and `&mut`: `$plane` is the matching plane
/// walk, `$m` is `mut` or nothing.
macro_rules! feature_walk {
($name:ident, $plane:ident $(, $m:tt)?) => {
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut(Ref<&'a $($m)? u64, &'a $($m)? String>)) {
use Ref::{Body as B, Feature as F, Sketch as S};
match self {
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } => $plane(plane, f),
FeatureKind::Extrude { sketch, targets, .. } | FeatureKind::Revolve { sketch, targets, .. } => {
f(S(sketch));
for b in targets {
f(B(b));
}
}
FeatureKind::Emboss { sketch, targets, face, .. } => {
f(S(sketch));
if let Some(face) = face {
f(B(& $($m)? face.body));
}
for b in targets {
f(B(b));
}
}
FeatureKind::SurfaceExtrude { sketch, .. }
| FeatureKind::SurfaceRevolve { sketch, .. }
| FeatureKind::Rib { sketch, .. }
| FeatureKind::SheetBase { sketch, .. }
| FeatureKind::SheetContour { sketch, .. } => f(S(sketch)),
FeatureKind::SurfaceSweep { sketch, path_sketch, .. } => {
f(S(sketch));
f(S(path_sketch));
}
FeatureKind::SurfaceLoft { sections } => {
for s in sections {
f(S(& $($m)? s.sketch));
}
}
FeatureKind::Sweep { sketch, path_sketch, targets, .. } => {
f(S(sketch));
f(S(path_sketch));
for b in targets {
f(B(b));
}
}
FeatureKind::Pipe { path_sketch, targets, .. } => {
f(S(path_sketch));
for b in targets {
f(B(b));
}
}
FeatureKind::Loft { sections, targets, .. } => {
for s in sections {
f(S(& $($m)? s.sketch));
}
for b in targets {
f(B(b));
}
}
FeatureKind::Coil { targets, .. } => {
for b in targets {
f(B(b));
}
}
FeatureKind::Hole { points, .. } => {
if let Some(p) = points {
f(S(& $($m)? p.sketch));
}
}
FeatureKind::Pattern { features, pattern, bodies } => {
for n in features {
f(F(n));
}
if let PatternKind::Path { path_sketch, .. } = pattern {
f(S(path_sketch));
}
for b in bodies {
f(B(b));
}
}
FeatureKind::Mirror { features, plane, bodies, .. } => {
for n in features {
f(F(n));
}
$plane(plane, f);
for b in bodies {
f(B(b));
}
}
FeatureKind::Split { body, plane, tool } | FeatureKind::SurfaceTrim { body, plane, tool, .. } => {
f(B(body));
$plane(plane, f);
if let Some(t) = tool {
f(B(& $($m)? t.body));
}
}
FeatureKind::SplitFace { body, plane, tool, sketch, .. } => {
$plane(plane, f);
if let Some(s) = sketch {
f(S(s));
}
if let Some(b) = body {
f(B(b));
}
if let Some(t) = tool {
f(B(& $($m)? t.body));
}
}
FeatureKind::Draft { neutral: plane, body, .. } | FeatureKind::ReplaceFace { target: plane, body, .. } => {
$plane(plane, f);
if let Some(b) = body {
f(B(b));
}
}
FeatureKind::SheetFold { sketch, body, .. } | FeatureKind::Patch { sketch, body, .. } => {
if let Some(s) = sketch {
f(S(s));
}
if let Some(b) = body {
f(B(b));
}
}
FeatureKind::Fillet { body, .. }
| FeatureKind::Chamfer { body, .. }
| FeatureKind::Shell { body, .. }
| FeatureKind::OffsetFace { body, .. }
| FeatureKind::DeleteFace { body, .. }
| FeatureKind::SheetFlange { body, .. }
| FeatureKind::SheetHem { body, .. }
| FeatureKind::SheetPerforate { body, .. }
| FeatureKind::SheetUnfold { body, .. }
| FeatureKind::Boss { body, .. }
| FeatureKind::Lip { body, .. }
| FeatureKind::SnapFit { body, .. }
| FeatureKind::Rest { body, .. }
| FeatureKind::SurfaceOffset { body, .. } => {
if let Some(b) = body {
f(B(b));
}
}
FeatureKind::SheetConvert { body, .. }
| FeatureKind::SurfaceExtend { body, .. }
| FeatureKind::Unstitch { body, .. }
| FeatureKind::ReverseNormal { body, .. }
| FeatureKind::MeshConvert { body, .. } => f(B(body)),
FeatureKind::Combine { target, tools, .. } => {
f(B(target));
for b in tools {
f(B(b));
}
}
FeatureKind::Thicken { bodies, targets, .. } => {
for b in bodies {
f(B(b));
}
for b in targets {
f(B(b));
}
}
FeatureKind::BodyTransfer { bodies, .. }
| FeatureKind::Scale { bodies, .. }
| FeatureKind::BoundingSolid { bodies, .. }
| FeatureKind::Align { bodies, .. }
| FeatureKind::Remove { bodies }
| FeatureKind::Stitch { bodies, .. }
| FeatureKind::Move { bodies, .. } => {
for b in bodies {
f(B(b));
}
}
FeatureKind::BoundaryFill { tools, .. } => {
for b in tools {
f(B(b));
}
}
// Construction axes and points refer to geometry, which copies remap on their own
// (`Feature::remap_clone`).
FeatureKind::ConstructionAxis { .. }
| FeatureKind::ConstructionPoint { .. }
| FeatureKind::Box { .. }
| FeatureKind::Cylinder { .. }
| FeatureKind::Sphere { .. }
| FeatureKind::Torus { .. }
| FeatureKind::Thread { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. } => {}
}
}
};
}
impl FeatureKind {
feature_walk!(walk_refs, plane_refs);
feature_walk!(walk_refs_mut, plane_refs_mut, mut);
/// Sketches the feature is built from (profiles, paths, points): deleting one of them
/// deletes the feature.
pub fn input_sketches(&self) -> Vec<u64> {
+1 -1
View File
@@ -141,7 +141,7 @@ const GOLDEN_REFS: &str = include_str!("feature_walk_tests/golden_refs.txt");
#[test]
fn input_sketches_lists_every_variant_with_a_sketch_field() {
let src = include_str!("document.rs");
let src = include_str!("document.rs").replace("\r\n", "\n");
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
let mut with_sketch = Vec::new();
let mut variant = String::new();
+26 -1
View File
@@ -75,6 +75,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("thread", json!({"type": "thread", "face": o, "length": "thread.length"})),
("loft", json!({"type": "loft", "sections": []})),
("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})),
(
"sweep_options",
json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}),
),
("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})),
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
@@ -147,11 +152,31 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}),
),
("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})),
(
"surface_sweep_options",
json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
),
("surface_loft", json!({"type": "surface_loft", "sections": []})),
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})),
("surface_extend", json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance"})),
(
"surface_extend_to_plane",
json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance", "mode": "curvature", "up_to": {"plane": plane("extend")}}),
),
(
"surface_extend_to_face",
json!({"type": "surface_extend", "body": "B", "edges": [o], "distance": "extend.distance", "up_to": {"face": {"body": "B", "point": o}}, "neighbours": false}),
),
(
"surface_ruled",
json!({"type": "surface_ruled", "body": "B", "edges": [o], "distance": "ruled.distance", "mode": "angle", "angle": "ruled.angle",
"direction": [0, 0, 1], "face": [1, 0, 0]}),
),
("surface_trim", json!({"type": "surface_trim", "body": "B", "plane": plane("trim"), "keep": o})),
("move", json!({"type": "move", "bodies": ["B"], "translate": ["move.x", "move.y", "move.z"], "angle": "move.angle"})),
("import", json!({"type": "import", "file": "a.step", "step": ""})),
@@ -167,7 +192,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
let o = [0, 0, 0];
let tool = json!({"body": "B", "point": o});
let extra = match name {
"extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
| "snap_fit" | "rest" | "surface_offset" => {
@@ -136,6 +136,11 @@ thread
expressions: thread.length
loft
sweep
sweep_options
inputs:
Twist = sweep.twist (Angle)
Scale = sweep.scale (Unitless)
expressions: sweep.twist, sweep.scale
shell
inputs:
Thickness = shell.thickness (Length)
@@ -317,7 +322,13 @@ surface_revolve
Angle 2 = srev.angle2 (Angle)
expressions: srev.angle, srev.angle2
surface_sweep
surface_sweep_options
inputs:
Twist = surface_sweep.twist (Angle)
Scale = surface_sweep.scale (Unitless)
expressions: surface_sweep.twist, surface_sweep.scale
surface_loft
surface_blend
surface_offset
inputs:
Distance = soff.distance (Length)
@@ -325,10 +336,26 @@ surface_offset
expressions: soff.distance, soff.tolerance
unstitch
reverse_normal
surface_untrim
surface_extend
inputs:
Distance = extend.distance (Length)
expressions: extend.distance
surface_extend_to_plane
inputs:
Distance = extend.distance (Length)
Angle = extend.angle (Angle)
Offset = extend.offset (Length)
expressions: extend.distance, extend.angle, extend.offset
surface_extend_to_face
inputs:
Distance = extend.distance (Length)
expressions: extend.distance
surface_ruled
inputs:
Distance = ruled.distance (Length)
Angle = ruled.angle (Angle)
expressions: ruled.distance, ruled.angle
surface_trim
inputs:
Angle = trim.angle (Angle)
@@ -63,6 +63,9 @@ loft
profiles: 4 all, 5 all
sweep
profiles: 1 all
sweep_options
moves: orientation.direction[1], orientation.direction[2]
profiles: 1 all
shell
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
@@ -142,7 +145,10 @@ surface_extrude
surface_revolve
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
surface_sweep
surface_sweep_options
surface_loft
surface_blend
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
surface_offset
moves: faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
@@ -150,8 +156,18 @@ unstitch
moves: faces[0][0], faces[0][1], faces[0][2]
reverse_normal
moves: faces[0][0], faces[0][1], faces[0][2]
surface_untrim
moves: face.point[0], face.point[1], face.point[2], holes[0][0], holes[0][1], holes[0][2]
face points: [0, 0, 0]
surface_extend
moves: edges[0][0], edges[0][1], edges[0][2]
surface_extend_to_plane
moves: edges[0][0], edges[0][1], edges[0][2], up_to.plane.axis_dir[0], up_to.plane.axis_dir[1], up_to.plane.axis_origin[0], up_to.plane.axis_origin[1], up_to.plane.axis_origin[2]
surface_extend_to_face
moves: edges[0][0], edges[0][1], edges[0][2], up_to.face.point[0], up_to.face.point[1], up_to.face.point[2]
surface_ruled
moves: direction[0], direction[2], edges[0][0], edges[0][1], edges[0][2], face[1], face[2]
face points: [1, 0, 0]
surface_trim
moves: keep[0], keep[1], keep[2], plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
move
@@ -67,6 +67,10 @@ sweep
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
remap_bodies: targets[0]
shell
remap_bodies: body
draft
@@ -159,17 +163,31 @@ surface_revolve
surface_sweep
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_sweep_options
sketches: [1, 2]
remap_refs: path_sketch, sketch
surface_loft
sketches: [4, 5]
remap_refs: sections[0].sketch, sections[1].sketch
surface_blend
remap_bodies: from.body
surface_offset
remap_bodies: body
unstitch
remap_bodies: body
reverse_normal
remap_bodies: body
surface_untrim
remap_bodies: face.body
surface_extend
remap_bodies: body
surface_extend_to_plane
remap_refs: up_to.plane.base.base.name
remap_bodies: body
surface_extend_to_face
remap_bodies: body, up_to.face.body
surface_ruled
remap_bodies: body
surface_trim
remap_refs: plane.base.base.name
remap_bodies: body, tool.body
+109 -3
View File
@@ -153,13 +153,82 @@
//! The saved visibility can also list hidden assembly joints (`visibility.hidden_joints`,
//! joint ids): their glyphs aren't drawn, but they still constrain and drive the parts. A
//! design without it shows every joint, as it always did; 0.10.0 would show them all.
//! - 21: named render setups (`render: {setups: {name: setup}}`): renderer-independent render
//! intent (camera, resolution, exposure, environment). Version 20 designs need no
//! transformation (an absent field means no setups, and empty render state is not saved); the
//! version stamp makes 0.11.0 (which reads up to 20) ask for an update instead of silently
//! dropping saved setups.
//! Also first saved in 21, a design can carry `metadata` (#1015): design properties
//! (`project`, `part_number`, `revision`, `author`, `organization`, `description`, `license`,
//! each text) and other tools' data, each tool's under its own reverse-DNS key
//! (`com.example.tool`). Every key is kept as it is, and nothing in it is checked when the
//! design opens. A design without it saves as before. 0.11.x refuses format 21, so no
//! released build drops the properties when it saves.
//! Under the same version, looks can explicitly select opaque PBR parameters (`pbr`: metal
//! fraction, roughness, dielectric IOR). Absent fields keep the scalar shading a look always
//! had; 0.11.0 asks for an update instead of silently dropping the response.
//! A `SurfaceUntrim` feature (Untrim: a `face` as `body` and `point`, optional `holes`
//! points, `outer` and `in_place`) is also first saved in 21; 0.11.0 asks for an update
//! instead of meeting a feature it can't read.
//! A `SurfaceRuled` feature (Ruled Surface: `body`, `edges` points, `distance`, optional
//! `mode`, `angle`, `direction`, `flip`, `corners` and `face`) is also first saved in 21;
//! 0.11.0 asks for an update instead of meeting a feature it can't read.
//! Under the same version, a `SurfaceExtend` can say how it continues the surface (`mode`:
//! `tangent` or `curvature`; `natural` when absent), extend up to something instead of its
//! `distance` (`up_to`: a `plane`, a `face` as `body` and `point`, or `bounds`) and keep the
//! neighbouring edges as they are (`neighbours: false`). An Extend without them builds the
//! planar extension it always did and saves as before; 0.11.0 asks for an update instead of
//! dropping them and extending a plane by the distance.
//! Under the same version, an extrude's extent records how a symmetric `distance` is read
//! (`convention`: `total_distance`, split evenly around the start plane, which new solid
//! extrudes save; or `legacy_per_side`). An extent without it keeps the per-side reading
//! designs always had, so their thickness and parameters don't change; 0.11.0 asks for an
//! update instead of doubling a total-distance extrude.
//! Under the same version, a Convert to Sheet Metal bend point can lie in the middle of a
//! chamfer (a flat strip between two faces), which then is one bend at the two faces' virtual
//! sharp; 0.11.0 bent the chamfer's nearest edge instead or found no edge there. Bend points
//! on edges convert as before.
//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat
//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the
//! rule's radius; 0.11.0 found no edge there or refused the curved face.
//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or
//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale`
//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses
//! format 21, so no released build sweeps a twisted or scaled profile straight.
//! Nothing new is saved for converted sheets' corners and bend ends, but a design converts as
//! the rule says when it is evaluated: corners between the walls take the rule's corner relief
//! and a bend ending beside more of the fixed face its bend relief (0.11.0 left every corner a
//! notch and cut no bend relief), and a wall on part of an edge bends only where it stands
//! (0.11.0 refused it).
//! - 22: an Import (STEP) feature can list `skip`: indices of the file's bodies it does not
//! build, because they are later instances of a part the file places more than once and the
//! other occurrences of that part's component show them (#1386: an imported file becomes a
//! component, an assembly in it nested components). Version 21 designs need no
//! transformation: an Import without it builds every body, as it always did, and designs
//! whose imports sit loose in a component are left as they are. The version stamp makes
//! 0.12.0 (which reads up to 21) ask for an update instead of building those instances again
//! on top of their occurrences.
//! Also under 22, a number in an expression can be written with a decimal comma outside
//! parentheses (`2,5 mm` is `2.5 mm`, #1347; inside a function's parentheses the comma still
//! separates its arguments). Nothing new is saved, but an expression typed that way is saved as
//! typed; 0.12.0 asks for an update instead of failing to evaluate it.
//! Also under 22, a design can hold a `SurfaceBlend` (Blend Surface: `from`/`to` edge chains of
//! two bodies, optional `continuity`, `bulge`, `align`, `stitch`), and a `Patch` can carry a
//! `crown` (0 by default, and then not saved: a bump that vanishes on the loop, so the face
//! still meets the picked edges). Version 21 designs need no transformation; 0.12.0 asks for an
//! update instead of meeting a feature it can't read or dropping a patch's crown.
//! Also under 22, a sketch text's link source can say that its place, height or angle are
//! expressions (`exprs`: `x`, `y`, `height`, `angle`, each an expression over the design's
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
//! dropping the expressions and keeping the text at its last numbers.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 20;
pub const FORMAT: u32 = 22;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -176,7 +245,7 @@ pub fn version_of(s: &str) -> Option<u32> {
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
type JsonStep = fn(&mut Value) -> Result<()>;
const JSON_STEPS: [JsonStep; 19] = [
const JSON_STEPS: [JsonStep; 21] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -196,6 +265,8 @@ const JSON_STEPS: [JsonStep; 19] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
@@ -394,7 +465,7 @@ pub fn read(s: &str) -> Result<Document> {
"this design was saved by a newer SolidCraft (file format {version}; this version reads formats up to {FORMAT}): update SolidCraft to open it"
)));
}
check_numbers(&v, 0)?;
check_design_numbers(&v)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
@@ -863,6 +934,16 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
/// outside what the kernel's tolerances serve.
const MAX_REAL: f64 = 1e9;
/// Every number the design itself holds is in range ([`check_numbers`]). `metadata` is left
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
/// already bounds its nesting.
fn check_design_numbers(v: &Value) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != "metadata").try_for_each(|(_, x)| check_numbers(x, 1)),
v => check_numbers(v, 0),
}
}
/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile
/// file is refused instead of overflowing the geometry later).
fn check_numbers(v: &Value, depth: usize) -> Result<()> {
@@ -1094,6 +1175,7 @@ mod tests {
sketch,
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "10".into(),
direction: Direction::Negative,
distance2: None,
@@ -1713,4 +1795,28 @@ mod tests {
let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into());
assert!(read(&json).unwrap_err().to_string().contains("damaged design"));
}
/// A sweep saved before its options existed loads as a plain sweep and saves as it was; the
/// options round-trip.
#[test]
fn sweep_options_are_optional_and_round_trip() {
use crate::{FeatureKind, SweepOrientation};
let old =
serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") };
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]});
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
assert_eq!(serde_json::to_value(&k).unwrap(), old);
let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y));
assert_eq!(serde_json::to_value(&k).unwrap(), new);
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
assert_eq!(parallel, SweepOrientation::Parallel);
}
}
+1
View File
@@ -427,6 +427,7 @@ mod tests {
sketch: if missing_sketch { 999_999 } else { id },
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
+408 -92
View File
@@ -349,22 +349,40 @@ fn mismatch(a: &Mat, b: &Mat) -> (f64, f64) {
(t, sin2.atan2(c(0, 0) + c(1, 1) + c(2, 2) - 1.0))
}
/// Passes over the motion links: a chain of this many links carries a value through in any order.
const MAX_LINK_HOPS: usize = 4;
/// Passes over the motion links at most: a chain of up to this many links carries its value
/// through, whatever order its links are stored in.
const MAX_LINK_PASSES: usize = 64;
/// Motion links in order (a few passes for chains), then the linked values' limits.
/// Motion links, pass after pass until nothing changes (a chain stored from its end takes a pass
/// per link), each linked value within its joint's limits as it is set. The result is the same
/// whatever the values were before and however the links are stored, so applying them again
/// changes nothing (links that form a cycle stop after `MAX_LINK_PASSES`).
fn apply_links(asm: &Assembly, vals: &mut Values) {
for _ in 0..MAX_LINK_HOPS {
let limits: BTreeMap<(u64, usize), (f64, f64)> = asm
.links
.iter()
.filter_map(|l| {
let (lo, hi) = asm.joints.iter().find(|j| j.id == l.b)?.limits.get(l.ib).copied().flatten()?;
Some(((l.b, l.ib), (lo.min(hi), hi.max(lo))))
})
.collect();
// The linked values, to see whether a pass changed any (several links writing one value
// change it within a pass, but settle from one pass to the next).
let linked = |vals: &Values| -> Vec<Option<f64>> { asm.links.iter().map(|l| vals.get(&l.b).and_then(|q| q.get(l.ib)).copied()).collect() };
for _ in 0..MAX_LINK_PASSES {
let before = linked(vals);
for l in &asm.links {
let Some(src) = vals.get(&l.a).and_then(|q| q.get(l.ia)).copied() else { continue };
let mut next = l.ratio * src + l.offset;
if let Some((lo, hi)) = limits.get(&(l.b, l.ib)) {
next = next.max(*lo).min(*hi);
}
if let Some(v) = vals.get_mut(&l.b).and_then(|q| q.get_mut(l.ib)) {
*v = l.ratio * src + l.offset;
*v = next;
}
}
}
for l in &asm.links {
if let (Some(j), Some(q)) = (asm.joints.iter().find(|j| j.id == l.b), vals.get_mut(&l.b)) {
clamp(j, q);
if linked(vals) == before {
break;
}
}
}
@@ -481,7 +499,7 @@ fn link_chains(asm: &Assembly, tree: &Tree, around: &BTreeSet<u64>, linked: &BTr
let mut frontier: BTreeSet<(u64, usize)> =
around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))).collect();
let mut seen = frontier.clone();
for _ in 0..MAX_LINK_HOPS {
for _ in 0..MAX_LINK_PASSES {
let next: BTreeSet<(u64, usize)> =
asm.links.iter().filter(|l| frontier.contains(&(l.b, l.ib))).map(|l| (l.a, l.ia)).filter(|v| !seen.contains(v)).collect();
if next.is_empty() {
@@ -526,35 +544,7 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals:
return (Values::new(), stuck);
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
// Each loop: its closing joint and the joints on the tree paths between its sides; loops that
// share a joint (or a value feeding them through links) are solved together.
let mut groups: Vec<(Vec<u64>, BTreeSet<u64>, BTreeSet<(u64, usize)>)> = Vec::new();
for id in closing {
let Some(j) = tree.joints.get(id) else { continue };
// A joint between an occurrence and itself (only a hand-edited file has one) is only checked.
if j.a.occurrence == j.b.occurrence {
continue;
}
let side = |occ: u64| -> BTreeSet<u64> { tree.path(occ).1.iter().map(|(k, _)| *k).collect() };
let mut around: BTreeSet<u64> = side(j.a.occurrence).symmetric_difference(&side(j.b.occurrence)).copied().collect();
around.insert(*id);
// The values the loop depends on: its joints' and those its links read. Loops tie together
// through a shared value only (a rigid joint on both paths has none; two values of one
// joint can feed two independent loops).
let mut touches = link_chains(asm, tree, &around, &linked).0;
touches.extend(around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))));
let mut members = vec![*id];
groups.retain(|(c, a, t)| {
if t.is_disjoint(&touches) {
return true;
}
members.extend(c.iter().copied());
around.extend(a.iter().copied());
touches.extend(t.iter().copied());
false
});
groups.push((members, around, touches));
}
let groups = loop_groups(tree, closing, asm, &linked);
let spent = std::cell::Cell::new(0usize);
for (group, around, touches) in &groups {
let moves = held_moves(asm, held, touches, vals);
@@ -581,6 +571,304 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals:
(moved, stuck)
}
/// A group of loops solved together: its closing joints, the joints around them, and the values
/// it touches.
type Group = (Vec<u64>, BTreeSet<u64>, BTreeSet<(u64, usize)>);
/// The loops' groups. Each loop: its closing joint and the joints on the tree paths between its
/// sides; loops that share a value (of a joint, or one feeding them through links) are solved
/// together.
fn loop_groups(tree: &Tree, closing: &[u64], asm: &Assembly, linked: &BTreeSet<(u64, usize)>) -> Vec<Group> {
let mut groups: Vec<Group> = Vec::new();
for id in closing {
let Some(j) = tree.joints.get(id) else { continue };
// A joint between an occurrence and itself (only a hand-edited file has one) is only checked.
if j.a.occurrence == j.b.occurrence {
continue;
}
let side = |occ: u64| -> BTreeSet<u64> { tree.path(occ).1.iter().map(|(k, _)| *k).collect() };
let mut around: BTreeSet<u64> = side(j.a.occurrence).symmetric_difference(&side(j.b.occurrence)).copied().collect();
around.insert(*id);
// The values the loop depends on: its joints' and those its links read. Loops tie together
// through a shared value only (a rigid joint on both paths has none; two values of one
// joint can feed two independent loops).
let mut touches = link_chains(asm, tree, &around, linked).0;
touches.extend(around.iter().filter_map(|id| tree.joints.get(id)).flat_map(|j| (0..j.kind.dofs().len()).map(|i| (j.id, i))));
let mut members = vec![*id];
groups.retain(|(c, a, t)| {
if t.is_disjoint(&touches) {
return true;
}
members.extend(c.iter().copied());
around.extend(a.iter().copied());
touches.extend(t.iter().copied());
false
});
groups.push((members, around, touches));
}
groups
}
/// The values a solve of the loops `around` moves: every value of their joints but the `held`
/// ones and motion-link targets, and the values the targets' link chains start from.
fn unknowns(tree: &Tree, around: &BTreeSet<u64>, asm: &Assembly, linked: &BTreeSet<(u64, usize)>, held: &Held) -> Vec<(u64, usize)> {
let mut vars: Vec<(u64, usize)> = Vec::new();
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
vars.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v) && !held.contains_key(v)));
}
for v in link_chains(asm, tree, around, linked).1 {
if !held.contains_key(&v) && !vars.contains(&v) {
vars.push(v);
}
}
vars
}
/// Each value's limits, or none.
fn limit_bounds(tree: &Tree, vars: &[(u64, usize)]) -> Vec<(f64, f64)> {
vars.iter()
.map(|(id, i)| match tree.joints.get(id).and_then(|j| j.limits.get(*i).copied().flatten()) {
Some((lo, hi)) => (lo.min(hi), hi.max(lo)),
None => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect()
}
/// The loops' size: the diagonal of the box around their joints' A frames, 1 mm to 1 km. Points
/// that far apart weigh turns like moves.
fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
for id in around {
let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue };
let Some(t) = tree.world(j.a.occurrence, vals) else { continue };
let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO);
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 }
}
/// How far each closing joint is from meeting: three points of B's joint frame, `scale` apart,
/// where the joint wants them against where the tree puts them (appended to `out`).
fn closure_gaps(tree: &Tree, closing: &[u64], vals: &Values, scale: f64, out: &mut Vec<f64>) -> Option<()> {
for id in closing {
let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?);
let tb = tree.world(j.b.occurrence, vals)?;
let want = tree.step(*id, true, &tree.world(j.a.occurrence, vals)?, vals)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let at = apply_point(fb, p);
let d = apply_point(&want, at) - apply_point(&tb, at);
out.extend([d.x, d.y, d.z]);
}
}
Some(())
}
/// Occurrences whose degrees of freedom are worked out through their loops, at most; the rest
/// keep the tree's count.
const MAX_DOF_OCCURRENCES: usize = 256;
/// How far `nearby_pose` moves each value: radians for an angle, a share of the loops' size for a
/// distance. Far enough for a part's motion that vanishes at a dead point to show in a rank, near
/// enough to stay on the mechanism's branch.
const NUDGE: f64 = 1e-3;
/// A pose of the loops near `vals`: each value they move nudged by an amount of its own within
/// its limits (so the nudge points in no special direction), then each group of loops closed
/// again. A group that can't close again, or only further away than a few nudges (on another
/// branch), keeps its values. It has a work budget of its own.
fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<(u64, usize)>, vals: &Values) -> Values {
let mut near = vals.clone();
let mut spent = 0usize;
for (closing, around, _) in groups {
let vars = unknowns(tree, around, asm, linked, &Held::new());
if vars.is_empty() || vars.len() > MAX_LOOP_VALUES {
continue;
}
let scale = loop_scale(tree, around, vals);
let step: Vec<f64> =
vars.iter().map(|(id, i)| NUDGE * if tree.joints.get(id).is_some_and(|j| j.kind.is_angle(*i)) { 1.0 } else { scale }).collect();
let bounds = limit_bounds(tree, &vars);
let x0: Vec<f64> = vars.iter().map(|(id, i)| near.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
let mut x: Vec<f64> = x0
.iter()
.zip(step.iter().zip(&bounds))
.enumerate()
.map(|(k, (v, (d, (lo, hi))))| {
// 0.5 to 1.5 nudges, alternately up and down.
let w = 0.5 + (k as f64 * 0.618_034).fract();
(v + d * if k % 2 == 0 { w } else { -w }).max(*lo).min(*hi)
})
.collect();
let mut work = near.clone();
let mut gaps = |x: &[f64]| -> Option<Vec<f64>> {
spent += 1;
if spent > MAX_LOOP_WORK {
return None;
}
for ((id, i), v) in vars.iter().zip(x) {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = *v;
}
}
apply_links(asm, &mut work);
let mut out = Vec::with_capacity(closing.len() * 9);
closure_gaps(tree, closing, &work, scale, &mut out)?;
out.iter().all(|v| v.is_finite()).then_some(out)
};
if least_squares(&mut gaps, &mut x, &bounds, (1e-10 * scale).min(1e-7))
&& x.iter().zip(&x0).zip(&step).all(|((a, b), d)| (a - b).abs() <= 4.0 * d)
&& gaps(&x).is_some()
{
near = work;
}
}
near
}
/// The degrees of freedom of the occurrences loops constrain: how many independent ways each can
/// still move with every loop kept closed, the rank of its placement over the joint values once
/// the loops' closure is taken out (rank [J_loops; J_placement] - rank J_loops). An occurrence's
/// values are those on its tree path and the unknowns of the loops on that path. It is counted at
/// the pose and at a pose nearby, and the larger count stands: at a dead point of its loop (an
/// actuator in line with the path of its tip pin) a part's motion vanishes to first order, so the
/// pose alone counts 0. The tree's count stands where a loop group is over the solver's caps or
/// the work runs out.
fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &mut BTreeMap<u64, usize>) {
if closing.len() > MAX_LOOPS {
return;
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let groups = loop_groups(tree, closing, asm, &linked);
// The joints loops depend on: those around them, those whose values they read, and those a
// motion link carries any of these values into.
let mut relevant: BTreeSet<u64> = groups.iter().flat_map(|(_, a, t)| a.iter().copied().chain(t.iter().map(|(j, _)| *j))).collect();
for _ in 0..MAX_LINK_PASSES {
let more: Vec<u64> = asm.links.iter().filter(|l| relevant.contains(&l.a) && !relevant.contains(&l.b)).map(|l| l.b).collect();
if more.is_empty() {
break;
}
relevant.extend(more);
}
// The occurrences a loop may hold or move: placed by such a joint, or below one that is
// (worked out down the tree, parents first). The others keep the tree's count.
let mut candidates: BTreeSet<u64> = BTreeSet::new();
for occ in &tree.order {
if let Some(&(from, joint, _)) = tree.parent.get(occ)
&& (relevant.contains(&joint) || candidates.contains(&from))
{
candidates.insert(*occ);
}
}
let (mut spent, mut counted) = (0usize, 0usize);
// A pose nearby, when one differs.
let near = (!candidates.is_empty()).then(|| nearby_pose(tree, &groups, asm, &linked, vals)).filter(|n| n != vals);
for occ in tree.order.iter().filter(|o| candidates.contains(o)) {
let path: Vec<u64> = tree.path(*occ).1.iter().map(|(k, _)| *k).collect();
let (mut values, mut have): (Vec<(u64, usize)>, BTreeSet<(u64, usize)>) = (Vec::new(), BTreeSet::new());
let mut add = |values: &mut Vec<(u64, usize)>, v: (u64, usize)| {
if have.insert(v) {
values.push(v);
}
};
for j in path.iter().filter_map(|k| tree.joints.get(k)) {
for v in (0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v)) {
add(&mut values, v);
}
if values.len() > MAX_LOOP_VALUES {
break;
}
}
if values.len() > MAX_LOOP_VALUES {
continue;
}
// A value on the path a motion link sets moves with the values its link chain starts from.
let on_path: BTreeSet<u64> = path.iter().copied().collect();
for v in link_chains(asm, tree, &on_path, &linked).1 {
add(&mut values, v);
}
// The loops on the path, and every loop whose values those reach (a linked source may be
// held by a loop of its own), each once.
let (mut closers, mut around) = (Vec::new(), BTreeSet::new());
let mut taken: BTreeSet<usize> = BTreeSet::new();
loop {
let more: Vec<usize> = groups
.iter()
.enumerate()
.filter(|(g, _)| !taken.contains(g))
.filter(|(_, (_, a, t))| path.iter().any(|k| a.contains(k)) || t.iter().any(|v| values.contains(v)))
.map(|(g, _)| g)
.collect();
if more.is_empty() {
break;
}
for (c, a, _) in more.iter().filter_map(|g| groups.get(*g)) {
closers.extend(c.iter().copied());
around.extend(a.iter().copied());
for v in unknowns(tree, a, asm, &linked, &Held::new()) {
add(&mut values, v);
}
}
taken.extend(more);
}
// No loop reaches this occurrence's values (not even through a motion link it drives):
// the tree's count stands.
if taken.is_empty() {
continue;
}
counted += 1;
if counted > MAX_DOF_OCCURRENCES || spent > MAX_LOOP_WORK {
return;
}
if values.len() > MAX_LOOP_VALUES {
continue;
}
let scale = loop_scale(tree, &around, vals);
let (n, gaps) = (values.len(), closers.len() * 9);
// How many ways the occurrence moves at a pose, with the loops kept closed.
let mut free_at = |at: &Values| -> Option<usize> {
let mut work = at.clone();
// The loops' gaps, then three points of the occurrence `scale` apart.
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
spent += 1;
for ((id, i), v) in values.iter().zip(x) {
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
*slot = *v;
}
}
apply_links(asm, &mut work);
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
closure_gaps(tree, &closers, &work, scale, &mut out)?;
let t = tree.world(*occ, &work)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let q = apply_point(&t, p);
out.extend([q.x, q.y, q.z]);
}
out.iter().all(|v| v.is_finite()).then_some(out)
};
let x0: Vec<f64> = values.iter().map(|(id, i)| at.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
let jac = jacobian(&mut f, &x0, gaps + 9)?;
let rank = |rows: usize| {
let mut a = linalg::Mat::zeros(rows, n);
let mut big = 0.0f64;
for r in 0..rows {
for c in 0..n {
let v = jac.get(r * n + c).copied().unwrap_or(0.0);
big = big.max(v.abs());
a.set(r, c, v);
}
}
linalg::rref(&mut a, 1e-6 * big.max(1.0)).len()
};
Some(rank(gaps + 9).saturating_sub(rank(gaps)))
};
let here = free_at(vals);
let there = near.as_ref().and_then(&mut free_at);
let Some(free) = here.max(there) else { continue };
dof.insert(*occ, free.min(6));
}
}
/// Close one group of loops: move the values of the joints `around` them (all but the `held`
/// values, motion-link targets and rigid joints, within their limits; a linked value moves
/// through the start of its link chain) until every loop-closing joint meets. A loop open at the
@@ -601,36 +889,12 @@ fn close_group(
spent: &std::cell::Cell<usize>,
vals: &mut Values,
) -> Option<BTreeMap<(u64, usize), f64>> {
let starts = link_chains(asm, tree, around, linked).1;
let mut vars: Vec<(u64, usize)> = Vec::new();
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
vars.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v) && !held.contains_key(v)));
}
for v in starts {
if !held.contains_key(&v) && !vars.contains(&v) {
vars.push(v);
}
}
let vars = unknowns(tree, around, asm, linked, held);
if vars.len() > MAX_LOOP_VALUES {
return Some(BTreeMap::new());
}
let bounds: Vec<(f64, f64)> = vars
.iter()
.map(|(id, i)| match tree.joints.get(id).and_then(|j| j.limits.get(*i).copied().flatten()) {
Some((lo, hi)) => (lo.min(hi), hi.max(lo)),
None => (f64::NEG_INFINITY, f64::INFINITY),
})
.collect();
// Turns weigh like moves through points a loop's size apart.
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
for id in around {
let (Some(j), Some((fa, _))) = (tree.joints.get(id), tree.frames.get(id)) else { continue };
let Some(t) = tree.world(j.a.occurrence, vals) else { continue };
let p = apply_point(&mat_mul(&t, fa), Vec3::ZERO);
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
}
let scale = if lo.x <= hi.x { (hi - lo).len().clamp(1.0, 1e6) } else { 1.0 };
let bounds = limit_bounds(tree, &vars);
let scale = loop_scale(tree, around, vals);
// Closer than the conflict check needs: 1e-7 mm, and turns of 1e-10 rad across the loop.
let tol = (1e-10 * scale).min(1e-7);
// The values with the held ones `t` of the way along (and the unknowns at `x`).
@@ -681,16 +945,7 @@ fn close_group(
}
place(&mut work, x, t);
let mut out = Vec::with_capacity(closing.len() * 9);
for id in closing {
let (j, (_, fb)) = (tree.joints.get(id)?, tree.frames.get(id)?);
let tb = tree.world(j.b.occurrence, &work)?;
let want = tree.step(*id, true, &tree.world(j.a.occurrence, &work)?, &work)?;
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
let at = apply_point(fb, p);
let d = apply_point(&want, at) - apply_point(&tb, at);
out.extend([d.x, d.y, d.z]);
}
}
closure_gaps(tree, closing, &work, scale, &mut out)?;
out.iter().all(|v| v.is_finite()).then_some(out)
};
// Already closed, with no held value moving. (A moving one is followed all the way: a
@@ -746,8 +1001,28 @@ fn largest(r: &[f64]) -> f64 {
/// Levenberg–Marquardt: move `x` (kept within `bounds`) until every value of `f` is within
/// `tol` of zero. False when it can't get there.
fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>, bounds: &[(f64, f64)], tol: f64) -> bool {
/// The Jacobian of `f` (`m` values) at `x` by central differences, row-major.
fn jacobian(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &[f64], m: usize) -> Option<Vec<f64>> {
const H: f64 = 1e-6;
let n = x.len();
let mut jac = vec![0.0; m * n];
for k in 0..n {
let (mut xp, mut xm) = (x.to_vec(), x.to_vec());
if let (Some(p), Some(q)) = (xp.get_mut(k), xm.get_mut(k)) {
*p += H;
*q -= H;
}
let (rp, rm) = (f(&xp)?, f(&xm)?);
for (i, (a, b)) in rp.iter().zip(&rm).enumerate().take(m) {
if let Some(slot) = jac.get_mut(i * n + k) {
*slot = (a - b) / (2.0 * H);
}
}
}
Some(jac)
}
fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>, bounds: &[(f64, f64)], tol: f64) -> bool {
let n = x.len();
let norm = |r: &[f64]| r.iter().map(|v| v * v).sum::<f64>();
let Some(mut r) = f(x) else { return false };
@@ -761,20 +1036,7 @@ fn least_squares(f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>
}
// The Jacobian by central differences, then the normal equations JᵀJ δ = −Jᵀr.
let m = r.len();
let mut jac = vec![0.0; m * n];
for k in 0..n {
let (mut xp, mut xm) = (x.clone(), x.clone());
if let (Some(p), Some(q)) = (xp.get_mut(k), xm.get_mut(k)) {
*p += H;
*q -= H;
}
let (Some(rp), Some(rm)) = (f(&xp), f(&xm)) else { return false };
for (i, (a, b)) in rp.iter().zip(&rm).enumerate() {
if let Some(slot) = jac.get_mut(i * n + k) {
*slot = (a - b) / (2.0 * H);
}
}
}
let Some(jac) = jacobian(f, x, m) else { return false };
let mut jtj = linalg::Mat::zeros(n, n);
let mut g = vec![0.0; n];
for (i, ri) in r.iter().enumerate() {
@@ -865,9 +1127,18 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
let mut used: BTreeSet<u64> = BTreeSet::new();
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
// The joints at each occurrence, in the joints' order (a large assembly is not walked joint by
// joint for every occurrence).
let mut at: BTreeMap<u64, Vec<&Joint>> = BTreeMap::new();
for j in joints.iter().copied() {
at.entry(j.a.occurrence).or_default().push(j);
if j.b.occurrence != j.a.occurrence {
at.entry(j.b.occurrence).or_default().push(j);
}
}
while let Some(occ) = queue.pop_front() {
let Some(t) = placed.get(&occ).copied() else { continue };
for j in &joints {
for j in at.get(&occ).map(Vec::as_slice).unwrap_or_default() {
if used.contains(&j.id) || !tree.frames.contains_key(&j.id) {
continue;
}
@@ -922,6 +1193,9 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
sol.conflicts.push((j.id, j.name.clone(), "the loop can't stay closed on the way to these values".into()));
}
}
if !closing.is_empty() {
loop_dof(&tree, &closing, asm, &vals, &mut dof);
}
for o in &doc.occurrences {
dof.entry(o.id).or_insert(6);
}
@@ -990,6 +1264,48 @@ mod tests {
t < 1e-9 && r < 1e-9
}
/// Revolute joints 1…n (joint 1 at `start`, the others at 0) linked 1 → 2 → … → n with
/// ratio 2, the links stored from the end of the chain; joint 3 limited to ±50.
fn chain(n: u64, start: f64) -> Assembly {
let origin = JointOrigin { occurrence: 0, snap: Snap::Point { point: Vec3::ZERO, z: None } };
let joints = (1..=n)
.map(|id| Joint {
id,
name: format!("J{id}"),
kind: JointKind::Revolute,
a: origin.clone(),
b: origin.clone(),
values: vec![if id == 1 { start } else { 0.0 }],
limits: vec![(id == 3).then_some((-50.0, 50.0))],
flip: false,
offset: 0.0,
angle: 0.0,
suppressed: false,
})
.collect();
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0 }).collect();
Assembly { joints, links, ..Assembly::default() }
}
/// Links carry a value down a chain stored from its end, each linked value within its limits
/// as it is set, and applying them again changes nothing.
#[test]
fn links_settle_whatever_order_they_are_stored_in() {
let asm = chain(8, 10.0);
let vals = joint_values(&asm);
let at = |id: u64| vals.get(&id).and_then(|q| q.first()).copied().unwrap_or(f64::NAN);
// 10 → 20 → 40 (within joint 3's limit) → 80 → … → 1280.
assert_eq!((at(1), at(2), at(3), at(4), at(8)), (10.0, 20.0, 40.0, 80.0, 1280.0));
let asm = chain(8, 40.0);
let vals = joint_values(&asm);
let at = |id: u64| vals.get(&id).and_then(|q| q.first()).copied().unwrap_or(f64::NAN);
// Joint 3 would be 160: held at 50, and what follows it follows the 50.
assert_eq!((at(3), at(4), at(8)), (50.0, 100.0, 1600.0));
let mut again = vals.clone();
apply_links(&asm, &mut again);
assert_eq!(again, vals);
}
#[test]
fn motions_compose() {
assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2)));
+9 -3
View File
@@ -9,12 +9,14 @@ pub mod appearance;
mod assembly;
mod body_transfer;
mod body_transfer_sheet;
pub mod pbr_appearance;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
mod clipboard;
pub use clipboard::{clone_expression, remap_topology};
pub mod config;
pub mod construct;
pub mod display_quality;
mod document;
mod eval;
pub mod expr;
@@ -28,6 +30,8 @@ mod history_names;
pub use format_offsets::whole_profile_selection_from_value;
pub mod joints;
mod lineage;
pub mod metadata;
pub use metadata::Metadata;
pub mod motion;
mod name_lookup;
pub mod naming;
@@ -36,10 +40,12 @@ pub mod physical_material;
mod piece_names;
mod profile_refs;
pub mod progress;
mod walk;
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
pub mod plastic;
pub mod project;
mod project3d;
pub mod render_settings;
pub use project3d::iso_grid;
pub mod sheet;
@@ -50,9 +56,9 @@ pub use assembly::{
pub use document::*;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for,
edge_refs, face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state,
world_state_reusing,
edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, object_height, parse_metric_thread, pattern_matrices,
revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance,
world_state, world_state_reusing,
};
pub use params::{ParamRow, ParamUser, cycles};
pub use solidcraft_kernel as kernel;
+263
View File
@@ -0,0 +1,263 @@
//! Design properties (#1015): the design's optional top-level `metadata` object.
//!
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
//! revision, author, organization, description, licence) and keeps every other key exactly as
//! it was through open, edit and save. Other tools keep their own data under one reverse-DNS
//! key each (`com.example.tool`), holding an object.
//!
//! Reading never validates: a well-known key holding something other than text is kept as it
//! is and reads as absent, so a file another tool wrote always opens. Writing through
//! [`Metadata::set`] (the `document.metadata.set` command) checks the key, the value's kind and
//! its size, so nothing SolidCraft writes is malformed.
//!
//! The map is stored as is: typed fields would refuse a design another tool had written
//! `"author": 42` into. Everything under `metadata` is informational, never geometry, so keeping
//! unknown keys can't leave stale model data behind (unlike keeping unknown top-level fields).
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{DocError, Result};
pub const PROJECT: &str = "project";
pub const PART_NUMBER: &str = "part_number";
pub const REVISION: &str = "revision";
pub const AUTHOR: &str = "author";
pub const ORGANIZATION: &str = "organization";
pub const DESCRIPTION: &str = "description";
pub const LICENSE: &str = "license";
/// SolidCraft's own keys, in the order the Design Properties dialog shows them. Every other
/// unprefixed key is reserved for SolidCraft too.
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
/// Longest key, in bytes.
pub const MAX_KEY_BYTES: usize = 128;
/// Longest well-known text value, in bytes.
pub const MAX_TEXT_BYTES: usize = 4096;
/// Largest namespaced value, as compact JSON.
pub const MAX_VALUE_BYTES: usize = 64 * 1024;
/// Deepest nesting of a namespaced value.
pub const MAX_VALUE_DEPTH: usize = 32;
/// Most keys, and largest object as compact JSON, that a change may grow the metadata to.
pub const MAX_KEYS: usize = 256;
pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
/// The design's `metadata` object.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Metadata(pub Map<String, Value>);
/// A key SolidCraft reads as a design property.
pub fn is_well_known(key: &str) -> bool {
WELL_KNOWN.contains(&key)
}
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
/// ASCII letters, digits, `-` and `_` (`com.example.tool`, `org.printfarm`).
pub fn is_namespaced(key: &str) -> bool {
key.len() <= MAX_KEY_BYTES
&& key.split('.').count() >= 2
&& key.split('.').all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'))
}
fn depth(v: &Value) -> usize {
// Iterative, so a hostile value can't overflow the stack here.
let mut deepest = 0;
let mut stack = vec![(v, 1usize)];
while let Some((v, d)) = stack.pop() {
deepest = deepest.max(d);
if d > MAX_VALUE_DEPTH {
break;
}
match v {
Value::Array(a) => stack.extend(a.iter().map(|x| (x, d + 1))),
Value::Object(m) => stack.extend(m.values().map(|x| (x, d + 1))),
_ => {}
}
}
deepest
}
fn json_len(v: &impl Serialize) -> usize {
serde_json::to_vec(v).map_or(usize::MAX, |b| b.len())
}
fn invalid(msg: String) -> DocError {
DocError::Invalid(msg)
}
impl Metadata {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.0.get(key)
}
/// A text value: `None` when the key is missing, empty or holds something other than text.
pub fn text(&self, key: &str) -> Option<&str> {
self.0.get(key)?.as_str().filter(|s| !s.is_empty())
}
pub fn project(&self) -> Option<&str> {
self.text(PROJECT)
}
pub fn part_number(&self) -> Option<&str> {
self.text(PART_NUMBER)
}
pub fn revision(&self) -> Option<&str> {
self.text(REVISION)
}
pub fn author(&self) -> Option<&str> {
self.text(AUTHOR)
}
pub fn organization(&self) -> Option<&str> {
self.text(ORGANIZATION)
}
pub fn description(&self) -> Option<&str> {
self.text(DESCRIPTION)
}
pub fn license(&self) -> Option<&str> {
self.text(LICENSE)
}
/// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added),
/// kept as they are.
pub fn others(&self) -> impl Iterator<Item = (&String, &Value)> {
self.0.iter().filter(|(k, _)| !is_well_known(k))
}
/// Set `key` to `value` after checking both; `null` (or empty text for a well-known key)
/// removes it. Returns whether the metadata changed.
pub fn set(&mut self, key: &str, value: Value) -> Result<bool> {
let value = check(key, value)?;
let Some(value) = value else { return Ok(self.remove(key)) };
if self.0.get(key) == Some(&value) {
return Ok(false);
}
let (keys, bytes) = (self.0.len(), json_len(&self.0));
let old = self.0.insert(key.to_string(), value);
// Growing past the limits is refused; a file already past them can still shrink.
let (new_keys, new_bytes) = (self.0.len(), json_len(&self.0));
if (new_keys > MAX_KEYS && new_keys > keys) || (new_bytes > MAX_TOTAL_BYTES && new_bytes > bytes) {
match old {
Some(old) => self.0.insert(key.to_string(), old),
None => self.0.remove(key),
};
return Err(invalid(format!("design properties are limited to {MAX_KEYS} keys and {} KiB", MAX_TOTAL_BYTES / 1024)));
}
Ok(true)
}
/// Remove `key`, whatever it holds. Returns whether it was there.
pub fn remove(&mut self, key: &str) -> bool {
self.0.remove(key).is_some()
}
}
/// `value` as `key` may hold it (`None`: remove the key), or why it can't.
fn check(key: &str, value: Value) -> Result<Option<Value>> {
if is_well_known(key) {
return match value {
Value::Null => Ok(None),
Value::String(s) if s.is_empty() => Ok(None),
Value::String(s) => {
if s.len() > MAX_TEXT_BYTES {
return Err(invalid(format!("`{key}` is longer than {MAX_TEXT_BYTES} bytes")));
}
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
return Err(invalid(format!("`{key}` has a control character")));
}
Ok(Some(Value::String(s)))
}
_ => Err(invalid(format!("`{key}` takes text"))),
};
}
if is_namespaced(key) {
return match value {
Value::Null => Ok(None),
Value::Object(m) => {
let v = Value::Object(m);
if depth(&v) > MAX_VALUE_DEPTH {
return Err(invalid(format!("`{key}` is nested more than {MAX_VALUE_DEPTH} levels deep")));
}
if json_len(&v) > MAX_VALUE_BYTES {
return Err(invalid(format!("`{key}` is larger than {} KiB", MAX_VALUE_BYTES / 1024)));
}
Ok(Some(v))
}
_ => Err(invalid(format!("`{key}` must hold an object (a tool keeps its own data as an object under its reverse-DNS key)"))),
};
}
let shown: String = key.chars().take(64).collect();
Err(invalid(format!(
"`{shown}` is not a design property: SolidCraft's are {}; another tool's data goes under a reverse-DNS key such as `com.example.tool`",
WELL_KNOWN.join(", ")
)))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn well_known_keys_read_as_text_and_ignore_other_kinds() {
let m: Metadata = serde_json::from_value(json!({"author": "Jane Doe", "revision": 3, "project": "", "license": null})).unwrap();
assert_eq!(m.author(), Some("Jane Doe"));
assert_eq!(m.revision(), None, "a number is kept but is not a revision");
assert_eq!(m.project(), None, "empty text reads as absent");
assert_eq!(m.license(), None);
assert_eq!(m.get("revision"), Some(&json!(3)), "kept as it was");
}
#[test]
fn set_checks_keys_kinds_and_sizes() {
let mut m = Metadata::default();
assert!(m.set("author", json!("Jane")).unwrap());
assert!(!m.set("author", json!("Jane")).unwrap(), "the same value is no change");
assert!(m.set("com.acme.tool", json!({"shop_id": 88123, "tags": ["a", null, 1.5]})).unwrap());
assert!(m.set("author", json!(42)).is_err(), "well-known keys take text");
assert!(m.set("author", json!("a\u{7}b")).is_err(), "no control characters");
assert!(m.set("description", json!("two\nlines\tand a tab")).unwrap());
assert!(m.set("com.acme.tool", json!("text")).is_err(), "a tool's key holds an object");
assert!(m.set("colour", json!("red")).is_err(), "unprefixed keys are reserved");
assert!(m.set("", json!({})).is_err());
for bad in ["com.", ".com", "com..x", "com.ex ample", "com.é", "single"] {
assert!(m.set(bad, json!({})).is_err(), "{bad}");
}
assert!(m.set("author", json!("x".repeat(MAX_TEXT_BYTES + 1))).is_err());
assert!(m.set("org.big", json!({"a": "x".repeat(MAX_VALUE_BYTES)})).is_err());
let mut deep = json!({});
for _ in 0..MAX_VALUE_DEPTH {
deep = json!({ "a": deep });
}
assert!(m.set("org.deep", deep).is_err());
assert_eq!(m.author(), Some("Jane"), "a refused value leaves the old one");
// Null and empty text remove.
assert!(m.set("author", json!("")).unwrap());
assert!(m.set("com.acme.tool", Value::Null).unwrap());
assert!(!m.set("com.acme.tool", Value::Null).unwrap());
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["description"]);
}
#[test]
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
let mut m = Metadata::default();
for i in 0..MAX_KEYS {
m.set(&format!("org.k{i}"), json!({})).unwrap();
}
assert!(m.set("org.one_more", json!({})).is_err());
assert_eq!(m.0.len(), MAX_KEYS);
// A file written past the limits by another tool still opens and can be cut down.
let mut big = Metadata::default();
for i in 0..MAX_KEYS + 10 {
big.0.insert(format!("org.k{i}"), json!({}));
}
assert!(big.set("org.k0", Value::Null).unwrap());
assert!(big.set("author", json!("Jane")).is_err(), "still over: no new keys");
assert!(big.set("org.k1", json!({"replaced": true})).is_ok(), "the count doesn't grow");
}
}
+27
View File
@@ -371,3 +371,30 @@ pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(std::iter::once((0, body)), name, p)
}
/// The one face a name `model.faces` lists finds, on the body called `body` or, without one, on
/// any body. `Err` says why there is no such face, or why the name does not name just one (the
/// pieces of a split face, or the same face on several bodies): a command that takes a face by
/// its name has no point to tell the pieces apart by.
pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<FaceHit, String> {
if let Some(n) = body
&& !st.bodies.iter().any(|b| b.name == n)
{
return Err(format!("no body `{n}`"));
}
let bodies: Vec<(usize, &ModelBody)> =
st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && body.is_none_or(|n| b.name == n)).collect();
// As [`locate_face`]: the names of several bodies are worked out on all cores first.
if bodies.len() > 1 && legacy_name(name).is_none() {
solidcraft_kernel::for_each_on_threads(&bodies, 16, |(_, b)| {
let _ = naming::names(b);
});
}
let hit = locate_in(bodies.into_iter(), name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
if hit.split {
return Err(format!(
"`{name}` names more than one face (pieces of a split face, or faces of several bodies): give `body`, or the name of one piece"
));
}
Ok(hit)
}
+66 -1
View File
@@ -76,7 +76,7 @@ impl NameCell {
}
}
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face};
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face, locate_one};
pub use crate::piece_names::parent_piece;
/// Deeper chains than this are named by position only (bounded work and stack).
@@ -756,6 +756,71 @@ pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
}
/// Where a face is, for a script: a point that lies on it and picks it, and the plane, cylinder,
/// cone, sphere or torus it is part of (when it is one).
#[derive(Clone, Copy, Debug)]
pub struct FaceSpot {
pub point: Vec3,
pub surface: Option<solidcraft_kernel::FaceSurface>,
}
/// The incentre and inradius of the roomiest triangle (greatest inradius) of each face of a mesh,
/// of face `only` alone if given.
fn roomiest(m: &Mesh, only: Option<usize>) -> Vec<Option<(f64, Vec3)>> {
let mut best = vec![None; m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0)];
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let f = *f as usize;
if only.is_some_and(|i| i != f) {
continue;
}
let Some([a, b, c]) = m.tri(t) else { continue };
// The incentre weighs each corner by the side opposite it; the inradius is area / half the perimeter.
let (la, lb, lc) = (b.dist(c), c.dist(a), a.dist(b));
let perimeter = la + lb + lc;
let radius = (b - a).cross(c - a).len() / perimeter;
let centre = (a * la + b * lb + c * lc) / perimeter;
if let Some(slot) = best.get_mut(f)
&& radius.is_finite()
&& centre.is_finite()
&& slot.is_none_or(|(old, _)| radius > old)
{
*slot = Some((radius, centre));
}
}
best
}
/// The spot of face `index` of `b` from its roomiest triangle: the triangle's incentre, so a point
/// that lies on the face however it is shaped (the centroid of a plate with a hole is in the hole,
/// that of a half cylinder inside the cylinder) and well away from its edges, so a pick there finds
/// this face. On a face of an analytic surface the point is moved onto the surface itself (a facet
/// of a curved face lies inside it by its sag), as far as that stays well inside the triangle.
fn spot(b: &ModelBody, index: usize, roomiest: Option<(f64, Vec3)>) -> Option<FaceSpot> {
let (radius, centre) = roomiest?;
let surface = solidcraft_kernel::face_surface(&b.body, index);
let point = surface.and_then(|s| s.nearest(centre)).filter(|q| q.dist(centre) <= radius * 0.5).unwrap_or(centre);
Some(FaceSpot { point, surface })
}
/// [`face_spot`] of every face of a body (`None`: the face has no triangles), in one pass.
pub fn face_spots(b: &ModelBody) -> Vec<Option<FaceSpot>> {
roomiest(&b.mesh(), None).into_iter().enumerate().map(|(i, best)| spot(b, i, best)).collect()
}
/// Where face `index` of `b` is (`None`: it has no triangles).
pub fn face_spot(b: &ModelBody, index: usize) -> Option<FaceSpot> {
spot(b, index, roomiest(&b.mesh(), Some(index)).get(index).copied().flatten())
}
/// A point on the face with this name, the name `model.faces` lists for it, on the body called
/// `body` or, without one, on any body (see [`locate_one`]): what a command that takes a face as
/// a point is given instead when the face is named.
pub fn named_face_point(st: &ModelState, body: Option<&str>, name: &str) -> Result<Vec3, String> {
let hit = locate_one(st, body, name)?;
let b = st.bodies.get(hit.body).ok_or_else(|| format!("no face is named `{name}`"))?;
face_spot(b, hit.face).map(|spot| spot.point).ok_or_else(|| format!("face `{name}` has no surface to pick"))
}
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
/// `true` for "not exactly that face": split, or merged into another); `None` when no face has
/// the name.
+44 -371
View File
@@ -9,7 +9,8 @@ use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::expr::{self, Kind, ParamDef, Value};
use crate::{DocError, Document, FeatureKind, HoleKind, MAX_PARAMS, PatternKind, PlaneRef, Result};
use crate::walk::Walk;
use crate::{DocError, Document, FeatureKind, MAX_PARAMS, Result};
/// Whether an expression of a feature is one of its inputs, which become model parameters named
/// in walk order (`dN`), or a coordinate of a primitive's placement point, which gets no name and
@@ -20,376 +21,11 @@ pub(crate) enum ExprRole {
Placement,
}
/// The offsets and angles of a plane, outermost first, for `&` and `&mut` (`$m` is `mut` or
/// nothing). Nesting is capped like every walk over a plane.
macro_rules! plane_walk {
($name:ident $(, $m:tt)?) => {
fn $name<'a>(mut p: &'a $($m)? PlaneRef, f: &mut impl FnMut(&'static str, &'a $($m)? String, Kind, ExprRole)) {
for _ in 0..64 {
match p {
PlaneRef::Offset { base, distance } => {
f("Offset", distance, Kind::Length, ExprRole::Input);
p = base;
}
PlaneRef::AtAngle { base, angle, .. } => {
f("Angle", angle, Kind::Angle, ExprRole::Input);
p = base;
}
PlaneRef::AlongPath { t, .. } => {
f("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input);
break;
}
_ => break,
}
}
}
};
}
plane_walk!(plane_exprs);
plane_walk!(plane_exprs_mut, mut);
/// Every expression of a feature with its label, kind and role, in a fixed order (the inputs'
/// order names their parameters), written once for `&` and `&mut`: `$plane` is the matching plane
/// walk, `$refs` the matching `refs`/`refs_mut`, `$m` is `mut` or nothing.
macro_rules! feature_walk {
($name:ident, $plane:ident, $refs:ident $(, $m:tt)?) => {
pub(crate) fn $name<'a>(&'a $($m)? self, f: &mut impl FnMut(&'static str, &'a $($m)? String, Kind, ExprRole)) {
use ExprRole::{Input as I, Placement as P};
use Kind::{Angle as A, Length as L, Unitless as U};
const XYZ: [&str; 3] = ["X", "Y", "Z"];
match self {
FeatureKind::Sketch { plane, .. }
| FeatureKind::ConstructionPlane { plane }
| FeatureKind::Split { plane, .. }
| FeatureKind::SplitFace { plane, .. }
| FeatureKind::Mirror { plane, .. } => $plane(plane, f),
FeatureKind::ConstructionAxis { def } => {
for r in def.$refs() {
if let crate::construct::GeoRef::Plane { plane } = r {
$plane(plane, f);
}
}
}
FeatureKind::ConstructionPoint { def } => match def {
crate::construct::PointDef::AlongPath { t, .. } => f("Distance (0 to 1)", t, U, I),
other => {
for r in other.$refs() {
if let crate::construct::GeoRef::Plane { plane } = r {
$plane(plane, f);
}
}
}
},
FeatureKind::Extrude { extent, thin, .. } => {
f("Distance", & $($m)? extent.distance, L, I);
if let Some(d) = & $($m)? extent.distance2 {
f("Distance 2", d, L, I);
}
if let Some(d) = & $($m)? extent.start_offset {
f("Start offset", d, L, I);
}
if let Some(d) = & $($m)? extent.taper {
f("Taper angle", d, A, I);
}
if let Some(d) = & $($m)? extent.to_offset {
f("To offset", d, L, I);
}
if let Some(thin) = thin {
f("Thickness", & $($m)? thin.thickness, L, I);
if let Some(t) = & $($m)? thin.thickness2 {
f("Thickness 2", t, L, I);
}
}
}
FeatureKind::SurfaceExtrude { extent, .. } => {
f("Distance", & $($m)? extent.distance, L, I);
if let Some(d) = & $($m)? extent.distance2 {
f("Distance 2", d, L, I);
}
if let Some(d) = & $($m)? extent.start_offset {
f("Start offset", d, L, I);
}
if let Some(d) = & $($m)? extent.taper {
f("Taper angle", d, A, I);
}
if let Some(d) = & $($m)? extent.to_offset {
f("To offset", d, L, I);
}
}
FeatureKind::Revolve { angle, angle2, .. } | FeatureKind::SurfaceRevolve { angle, angle2, .. } => {
f("Angle", angle, A, I);
if let Some(a) = angle2 {
f("Angle 2", a, A, I);
}
}
FeatureKind::Fillet { radius, style, .. } => match style {
crate::FilletStyle::Constant => f("Radius", radius, L, I),
crate::FilletStyle::Curvature => f("Equivalent radius", radius, L, I),
crate::FilletStyle::Chord => f("Chord length", radius, L, I),
crate::FilletStyle::Variable { radius2, mid, .. } => {
f("Start radius", radius, L, I);
f("End radius", radius2, L, I);
for (_, r) in mid {
f("Mid radius", r, L, I);
}
}
crate::FilletStyle::Spline { radii, .. } => {
f("Start radius", radius, L, I);
let n = radii.len();
for (i, r) in radii.into_iter().enumerate() {
f(if i + 1 == n { "End radius" } else { "Control radius" }, r, L, I);
}
}
},
FeatureKind::Chamfer { distance, distance2, angle, .. } => {
f("Distance", distance, L, I);
if let Some(d) = distance2 {
f("Distance 2", d, L, I);
}
if let Some(a) = angle {
f("Angle", a, A, I);
}
}
FeatureKind::Box { length, width, height, corner, .. } => {
f("Length", length, L, I);
f("Width", width, L, I);
f("Height", height, L, I);
for (l, c) in XYZ.into_iter().zip(corner) {
f(l, c, L, P);
}
}
FeatureKind::Cylinder { radius, height, base, .. } => {
f("Radius", radius, L, I);
f("Height", height, L, I);
for (l, c) in XYZ.into_iter().zip(base) {
f(l, c, L, P);
}
}
FeatureKind::Sphere { radius, center, .. } => {
f("Radius", radius, L, I);
for (l, c) in XYZ.into_iter().zip(center) {
f(l, c, L, P);
}
}
FeatureKind::Torus { major, minor, center, .. } => {
f("Major radius", major, L, I);
f("Minor radius", minor, L, I);
for (l, c) in XYZ.into_iter().zip(center) {
f(l, c, L, P);
}
}
FeatureKind::Pattern { pattern, .. } => match pattern {
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
f("Quantity", count1, U, I);
f("Spacing", spacing1, L, I);
if let Some(c) = count2 {
f("Quantity 2", c, U, I);
}
if let Some(sp) = spacing2 {
f("Spacing 2", sp, L, I);
}
}
PatternKind::Path { count, spacing, .. } => {
f("Quantity", count, U, I);
f("Distance", spacing, L, I);
}
PatternKind::Circular { count, angle, .. } => {
f("Quantity", count, U, I);
f("Total angle", angle, A, I);
}
},
FeatureKind::Shell { thickness, .. } => f("Thickness", thickness, L, I),
FeatureKind::Draft { angle, neutral, .. } => {
f("Angle", angle, A, I);
$plane(neutral, f);
}
FeatureKind::Hole { diameter, depth, hole, .. } => {
f("Diameter", diameter, L, I);
if let Some(d) = depth {
f("Depth", d, L, I);
}
match hole {
HoleKind::Simple => {}
HoleKind::Drilled { tip_angle } => f("Tip angle", tip_angle, A, I),
HoleKind::Counterbore { cb_diameter, cb_depth } => {
f("Counterbore diameter", cb_diameter, L, I);
f("Counterbore depth", cb_depth, L, I);
}
HoleKind::Countersink { cs_diameter, cs_angle } => {
f("Countersink diameter", cs_diameter, L, I);
f("Countersink angle", cs_angle, A, I);
}
}
}
FeatureKind::Thread { length, .. } => {
if let Some(l) = length {
f("Length", l, L, I);
}
}
FeatureKind::Scale { factor, factors, .. } => {
f("Scale factor", factor, U, I);
if let Some([x, y, z]) = factors {
f("X scale", x, U, I);
f("Y scale", y, U, I);
f("Z scale", z, U, I);
}
}
FeatureKind::OffsetFace { distance, .. } => f("Distance", distance, L, I),
FeatureKind::BoundingSolid { margin, .. } => f("Margin", margin, L, I),
FeatureKind::Pipe { diameter, wall, .. } => {
f("Diameter", diameter, L, I);
if let Some(w) = wall {
f("Wall", w, L, I);
}
}
FeatureKind::Move { translate, angle, .. } => {
let [x, y, z] = translate;
f("X distance", x, L, I);
f("Y distance", y, L, I);
f("Z distance", z, L, I);
if let Some(a) = angle {
f("Angle", a, A, I);
}
}
FeatureKind::Emboss { depth, .. } => f("Depth", depth, L, I),
FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => {
f("Diameter", diameter, L, I);
f("Height", height, L, I);
for (l, o, k) in [
("Hole diameter", hole_diameter, L),
("Hole depth", hole_depth, L),
("Draft angle", draft, A),
("Root fillet", fillet, L),
("Ribs", ribs, U),
("Rib thickness", rib_thickness, L),
("Rib length", rib_length, L),
("Rib offset", rib_offset, L),
] {
if let Some(e) = o {
f(l, e, k, I);
}
}
}
FeatureKind::Lip { width, height, gap, .. } => {
f("Width", width, L, I);
f("Height", height, L, I);
if let Some(g) = gap {
f("Gap", g, L, I);
}
}
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
f("Width", width, L, I);
f("Height", height, L, I);
for (l, o, k) in [("Length", length, L), ("Draft angle", draft, A), ("Wall thickness", thickness, L)] {
if let Some(e) = o {
f(l, e, k, I);
}
}
}
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
f("Length", length, L, I);
f("Thickness", thickness, L, I);
f("Width", width, L, I);
f("Catch depth", catch_depth, L, I);
f("Catch length", catch_length, L, I);
}
FeatureKind::SheetContour { distance, .. } => f("Distance", distance, L, I),
FeatureKind::SheetFlange { edges, height, angle, radius, .. } => {
f("Height", height, L, I);
f("Angle", angle, A, I);
if let Some(r) = radius {
f("Bend radius", r, L, I);
}
// The edges' own values come last, so the inputs above keep their names.
for e in edges {
if let Some(a) = & $($m)? e.angle {
f("Edge angle", a, A, I);
}
if let Some(h) = & $($m)? e.height {
f("Edge height", h, L, I);
}
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
f("Angle", angle, A, I);
if let Some(r) = radius {
f("Bend radius", r, L, I);
}
}
FeatureKind::SheetHem { length, gap, .. } => {
f("Length", length, L, I);
if let Some(g) = gap {
f("Gap", g, L, I);
}
}
FeatureKind::SheetPerforate { diameter, pitch, margin, .. } => {
f("Diameter", diameter, L, I);
f("Pitch", pitch, L, I);
if let Some(m) = margin {
f("Margin", m, L, I);
}
}
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, base, .. } => {
f("Diameter", diameter, L, I);
f("Pitch", pitch, L, I);
f("Revolutions", turns, U, I);
f("Section size", section_size, L, I);
if let Some(a) = start_angle {
f("Start angle", a, A, I);
}
for (l, c) in XYZ.into_iter().zip(base) {
f(l, c, L, P);
}
}
FeatureKind::Rib { thickness, depth, .. } => {
f("Thickness", thickness, L, I);
if let Some(d) = depth {
f("Depth", d, L, I);
}
}
FeatureKind::ReplaceFace { target, .. } => $plane(target, f),
FeatureKind::Stitch { tolerance, .. } => f("Tolerance", tolerance, L, I),
FeatureKind::MeshConvert { tolerance, .. } => f("Tolerance", tolerance, L, I),
FeatureKind::Thicken { thickness, .. } => f("Thickness", thickness, L, I),
FeatureKind::SurfaceTrim { plane, .. } => $plane(plane, f),
FeatureKind::SurfaceExtend { distance, .. } => f("Distance", distance, L, I),
FeatureKind::Unstitch { .. } | FeatureKind::ReverseNormal { .. } => {}
FeatureKind::SurfaceOffset { distance, tolerance, .. } => {
f("Distance", distance, L, I);
if let Some(t) = tolerance {
f("Tolerance", t, L, I);
}
}
FeatureKind::Patch { .. } => {}
FeatureKind::Combine { .. }
| FeatureKind::SheetBase { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
| FeatureKind::Align { .. }
| FeatureKind::Remove { .. }
| FeatureKind::DeleteFace { .. }
| FeatureKind::BoundaryFill { .. }
| FeatureKind::Loft { .. }
| FeatureKind::Sweep { .. }
| FeatureKind::SurfaceSweep { .. }
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::Import { .. }
| FeatureKind::MeshImport { .. }
| FeatureKind::BodyTransfer { .. } => {}
}
}
};
}
impl FeatureKind {
feature_walk!(walk_exprs, plane_exprs, refs);
feature_walk!(walk_exprs_mut, plane_exprs_mut, refs_mut, mut);
/// The feature's numeric inputs: (label, expression, kind), in a fixed order.
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> {
let mut v = Vec::new();
self.walk_exprs_mut(&mut |label, e, kind, role| {
self.walk_exprs_mut(&mut |(label, e, kind, role)| {
if role == ExprRole::Input {
v.push((label, e, kind));
}
@@ -400,7 +36,7 @@ impl FeatureKind {
/// Read-only view of [`FeatureKind::inputs_mut`]: (label, expression, kind).
pub fn inputs(&self) -> Vec<(&'static str, String, Kind)> {
let mut v = Vec::new();
self.walk_exprs(&mut |label, e, kind, role| {
self.walk_exprs(&mut |(label, e, kind, role)| {
if role == ExprRole::Input {
v.push((label, e.clone(), kind));
}
@@ -410,9 +46,13 @@ impl FeatureKind {
/// Every expression the feature uses: its inputs, then a primitive's placement coordinates.
pub fn expressions(&self) -> Vec<&str> {
let mut v = Vec::new();
self.walk_exprs(&mut |_, e, _, _| v.push(e.as_str()));
v
let (mut inputs, mut placement) = (Vec::new(), Vec::new());
self.walk_exprs(&mut |(_, e, _, role)| match role {
ExprRole::Input => inputs.push(e.as_str()),
ExprRole::Placement => placement.push(e.as_str()),
});
inputs.extend(placement);
inputs
}
}
@@ -712,6 +352,25 @@ impl Document {
rows
}
/// Move user parameter `name` to just before (or with `after`, just after) user parameter
/// `target`: the order the Parameters dialog lists them in. Nothing else changes.
pub fn move_user_param(&mut self, name: &str, target: &str, after: bool) -> Result<()> {
fn user(params: &[crate::Param], n: &str) -> Option<usize> {
params.iter().position(|p| p.name == n && !p.model)
}
let (Some(from), Some(_)) = (user(&self.params, name), user(&self.params, target)) else {
let missing = if user(&self.params, name).is_none() { name } else { target };
return Err(DocError::Unknown(format!("user parameter `{missing}`")));
};
if name == target {
return Err(DocError::Invalid("a parameter cannot move next to itself".into()));
}
let moved = self.params.remove(from);
let at = user(&self.params, target).map_or(self.params.len(), |i| if after { i + 1 } else { i });
self.params.insert(at, moved);
Ok(())
}
pub fn set_favorite(&mut self, name: &str, on: bool) -> Result<()> {
if !self.name_taken(name) {
return Err(DocError::Unknown(format!("parameter `{name}`")));
@@ -925,6 +584,11 @@ impl Document {
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
}
}
for (what, e) in sketch.text_exprs() {
if expr::references(e).iter().any(|r| r == name) {
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
}
}
}
}
out
@@ -1003,6 +667,13 @@ impl Document {
n += 1;
}
}
for e in sketch.text_exprs_mut() {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
}
}
if self.favorites.remove(old) {
@@ -1058,6 +729,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.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
}
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
}
@@ -1085,6 +757,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.text_exprs().iter().any(|(_, e)| expr::references(e).iter().any(|r| affected.contains(r)));
}
if hit {
out.push(f.id);
+87
View File
@@ -0,0 +1,87 @@
//! Persistable opaque optical parameters, independent of engineering material data.
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use solidcraft_render::surface::{OpaqueSurface, SurfaceError, srgb8_to_linear};
/// Explicitly selected opaque response. Existing scalar looks have no such record.
/// Construction and deserialization share the renderer's finite admission bounds.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "Parameters", into = "Parameters")]
pub struct PbrParameters {
metallic: f64,
roughness: f64,
dielectric_ior: f64,
}
impl Default for PbrParameters {
fn default() -> Self {
Self { metallic: 0.0, roughness: 0.5, dielectric_ior: 1.5 }
}
}
#[derive(Clone, Copy, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Parameters {
metallic: f64,
roughness: f64,
dielectric_ior: f64,
}
impl PbrParameters {
pub fn new(metallic: f64, roughness: f64, dielectric_ior: f64) -> Result<Self, SurfaceError> {
OpaqueSurface::new(Vec3::ZERO, metallic, roughness, dielectric_ior)?;
Ok(Self { metallic, roughness, dielectric_ior })
}
/// Decode the containing look's sRGB8 base color once, for the actual consumer.
pub fn surface(self, color: [u8; 3]) -> Result<OpaqueSurface, SurfaceError> {
let [r, g, b] = color.map(srgb8_to_linear);
OpaqueSurface::new(Vec3::new(r, g, b), self.metallic, self.roughness, self.dielectric_ior)
}
pub fn metallic(self) -> f64 {
self.metallic
}
pub fn roughness(self) -> f64 {
self.roughness
}
pub fn dielectric_ior(self) -> f64 {
self.dielectric_ior
}
}
impl TryFrom<Parameters> for PbrParameters {
type Error = SurfaceError;
fn try_from(p: Parameters) -> Result<Self, Self::Error> {
Self::new(p.metallic, p.roughness, p.dielectric_ior)
}
}
impl From<PbrParameters> for Parameters {
fn from(p: PbrParameters) -> Self {
Self { metallic: p.metallic, roughness: p.roughness, dielectric_ior: p.dielectric_ior }
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn optical_parameters_share_strict_admission_and_round_trip() {
let p = PbrParameters::new(1.0, 0.3, 1.5).unwrap();
let saved = serde_json::to_value(p).unwrap();
assert_eq!(serde_json::from_value::<PbrParameters>(saved.clone()).unwrap(), p);
for (key, value) in [("metallic", json!(1.1)), ("roughness", json!(0)), ("dielectric_ior", json!(0.9)), ("extra", json!(true))] {
let mut bad = saved.clone();
bad[key] = value;
assert!(serde_json::from_value::<PbrParameters>(bad).is_err());
}
assert!(PbrParameters::new(f64::NAN, 0.3, 1.5).is_err());
assert!(PbrParameters::new(0.0, f64::INFINITY, 1.5).is_err());
let surface = p.surface([128, 0, 255]).unwrap();
assert!((surface.base_color().x - 0.21586050011389926).abs() < 1e-14);
assert_eq!(surface.base_color().z, 1.0);
}
}
+20 -6
View File
@@ -3,7 +3,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use crate::{DocError, Result};
@@ -116,6 +116,10 @@ pub struct Validation {
pub property_kinds: Vec<String>,
pub isotropic_moduli: Option<IsotropicModuli>,
pub unspecified_conditions: Vec<String>,
/// Property kind -> absent condition fields under the existing family policy.
/// Empty when reading an older report that did not include this detail.
#[serde(default)]
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
pub source_review_required: Vec<String>,
pub analysis_solver_available: bool,
pub rendering_properties_available: bool,
@@ -256,6 +260,7 @@ impl MaterialCard {
let mut kinds = BTreeSet::new();
let mut used_sources = BTreeSet::new();
let mut unspecified = Vec::new();
let mut missing_condition_fields = BTreeMap::new();
let mut isotropic_moduli = None;
for property in &self.properties {
let kind = property.data.kind();
@@ -279,12 +284,20 @@ impl MaterialCard {
if let Some(state) = &conditions.state {
text(state, 1024, "conditions.state")?;
}
if conditions.temperature_k.is_none()
|| conditions.state.is_none()
|| (self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none())
|| (self.family == Family::Polymer && conditions.strain_rate_per_s.is_none())
{
let mut missing = Vec::new();
for (absent, field) in [
(conditions.temperature_k.is_none(), "temperature_k"),
(conditions.state.is_none(), "state"),
(self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none(), "moisture_dry_basis_kg_kg"),
(self.family == Family::Polymer && conditions.strain_rate_per_s.is_none(), "strain_rate_per_s"),
] {
if absent {
missing.push(field.to_string());
}
}
if !missing.is_empty() {
unspecified.push(kind.to_string());
missing_condition_fields.insert(kind.to_string(), missing);
}
if let Some(moduli) = property.data.validate()? {
isotropic_moduli = Some(moduli);
@@ -295,6 +308,7 @@ impl MaterialCard {
property_kinds: kinds.into_iter().collect(),
isotropic_moduli,
unspecified_conditions: unspecified,
missing_condition_fields,
source_review_required: used_sources.into_iter().collect(),
analysis_solver_available: false,
rendering_properties_available: false,
+52
View File
@@ -132,6 +132,58 @@ fn conditions_missing_values_and_source_claims_remain_explicit() {
assert_eq!(serde_json::from_value::<MaterialCard>(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed);
}
#[test]
fn missing_condition_fields_follow_family_policy_without_inventing_values() {
for (family, extra) in
[("wood", Some("moisture_dry_basis_kg_kg")), ("polymer", Some("strain_rate_per_s")), ("glass", None), ("metal", None), ("other", None)]
{
let mut value = card(json!({"kind":"density","kg_m3":600.}));
value["family"] = json!(family);
let mut expected = vec!["temperature_k", "state"];
expected.extend(extra);
let result = validate_card(&value).unwrap();
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), expected.iter().map(|s| (*s).into()).collect())]));
assert_eq!(result.unspecified_conditions, vec!["density"]);
// Explicit zero dry-basis moisture is known; no wet/dry conversion or default is inferred.
value["properties"][0]["conditions"] =
json!({"temperature_k":293.15,"state":"synthetic","moisture_dry_basis_kg_kg":0.,"strain_rate_per_s":1.});
assert!(validate_card(&value).unwrap().missing_condition_fields.is_empty());
for field in expected {
let mut partial = value.clone();
partial["properties"][0]["conditions"].as_object_mut().unwrap().remove(field);
let result = validate_card(&partial).unwrap();
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), vec![field.into()])]));
assert_eq!(result.unspecified_conditions, vec!["density"]);
}
value["properties"] = json!([]);
let result = validate_card(&value).unwrap();
assert!(result.missing_condition_fields.is_empty() && result.unspecified_conditions.is_empty());
}
}
#[test]
fn missing_condition_fields_are_per_property_and_reports_remain_backward_readable() {
let mut value = card(json!({"kind":"density","kg_m3":600.}));
value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"state":"synthetic"});
value["properties"].as_array_mut().unwrap().push(json!({"data":isotropic(12e9,0.25),"source_id":"test",
"conditions":{"state":"synthetic","moisture_dry_basis_kg_kg":1.5}}));
let before = value.clone();
let result = validate_card(&value).unwrap();
let expected = json!({"density":["moisture_dry_basis_kg_kg"],"elasticity":["temperature_k"]});
let serialized = serde_json::to_value(&result).unwrap();
assert_eq!(serialized["missing_condition_fields"], expected);
assert_eq!(result.unspecified_conditions, vec!["density", "elasticity"]);
assert_eq!(value, before);
assert_eq!(serde_json::from_value::<Validation>(serialized.clone()).unwrap(), result);
let mut old = serialized;
old.as_object_mut().unwrap().remove("missing_condition_fields");
let old: Validation = serde_json::from_value(old).unwrap();
assert!(old.missing_condition_fields.is_empty());
assert_eq!(old.unspecified_conditions, result.unspecified_conditions);
assert_eq!(old.source_review_required, result.source_review_required);
}
#[test]
fn thermal_properties_accept_negative_expansion_but_not_negative_conductivity() {
assert!(validate_card(&card(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}))).is_ok());
+2
View File
@@ -312,6 +312,7 @@ mod tests {
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
@@ -438,6 +439,7 @@ mod tests {
sketch: feature,
profiles: selection.clone(),
extent: crate::Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "2".into(),
direction: crate::Direction::Positive,
distance2: None,
+1 -1
View File
@@ -255,7 +255,7 @@ fn resolve_uncached(
LinkSource::SketchAxis { name } => {
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
}
LinkSource::Text { text, at, height, angle, font, style } if style.path.is_none() => {
LinkSource::Text { text, at, height, angle, font, style, .. } if style.path.is_none() => {
solidcraft_sketch::text_geometry_styled(font.as_deref(), text, *at, *height, *angle, style, None)?
}
// Its path is a curve of its own sketch: set with the sketch (`Sketch::text_source_geometry`).
+274
View File
@@ -0,0 +1,274 @@
//! Saved render intent, independent of runtime backend availability and GPU resources.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use solidcraft_render::{BackendId, Camera, RenderFormat, RenderLimits, RenderMode, UpAxis};
use crate::{DocError, Result};
pub const MAX_RENDER_SETUPS: usize = 32;
pub const MAX_SETUP_NAME_BYTES: usize = 128;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RenderSetup {
pub backend: BackendId,
pub mode: RenderMode,
#[serde(deserialize_with = "SavedCamera::deserialize")]
pub camera: Camera,
pub width: u32,
pub height: u32,
pub format: RenderFormat,
pub exposure_ev: f64,
/// Procedural environment orientation, in radians. No HDRI is loaded in R2.
pub environment_rotation: f64,
pub environment_intensity: f64,
/// sRGB background. Alpha/output colour management belong to rendering phases.
pub background: [u8; 3],
}
// Reuse Camera's representation and defaults, but reject misspelled saved fields. A remote
// derive constructs Camera itself, so adding a Camera field requires updating this adapter.
// Navigation/named-view deserialization keeps its existing compatibility policy.
#[derive(Deserialize)]
#[serde(remote = "Camera", deny_unknown_fields)]
struct SavedCamera {
target: Vec3,
yaw: f64,
pitch: f64,
#[serde(default)]
roll: f64,
distance: f64,
fov: f64,
#[serde(default)]
up: UpAxis,
}
impl Default for RenderSetup {
fn default() -> Self {
// The built-in descriptor owns the canonical default backend identifier.
Self {
backend: solidcraft_render::builtin_backend().id,
mode: RenderMode::Preview,
camera: Camera::default(),
width: 1024,
height: 768,
format: RenderFormat::Png,
exposure_ev: 0.0,
environment_rotation: 0.0,
environment_intensity: 1.0,
background: [236, 240, 245],
}
}
}
impl RenderSetup {
pub fn validate(&self) -> Result<()> {
let limits = RenderLimits::default();
let (width, height) = (u64::from(self.width), u64::from(self.height));
if width == 0 || height == 0 {
return Err(DocError::Invalid("render output must be at least 1 × 1 pixels".into()));
}
if let Some((w, h)) = limits.fit(width, height, None)
&& (w, h) != (width, height)
{
return Err(DocError::Invalid(format!(
"render output {width} × {height} exceeds the image limits ({} pixels per side, {} pixels in all); \
the largest image at this aspect is {w} × {h}",
limits.max_dimension, limits.max_pixels
)));
}
let camera = &self.camera;
if !camera.is_valid()
|| [camera.target.x, camera.target.y, camera.target.z, camera.distance].into_iter().any(|x| x.abs() > 1e9)
|| [camera.yaw, camera.pitch, camera.roll].into_iter().any(|x| x.abs() > 1e6)
|| !camera.fov.is_finite()
|| !(0.0..std::f64::consts::PI).contains(&camera.fov)
{
return Err(DocError::Invalid("invalid render camera (finite mm/radians, positive distance, 0 <= fov < pi required)".into()));
}
if !self.exposure_ev.is_finite() || !(-32.0..=32.0).contains(&self.exposure_ev) {
return Err(DocError::Invalid("render exposure must be finite and between -32 and 32 EV".into()));
}
if !self.environment_rotation.is_finite() || self.environment_rotation.abs() > 1e6 {
return Err(DocError::Invalid("render environment rotation must be finite, bounded radians".into()));
}
if !self.environment_intensity.is_finite() || !(0.0..=100.0).contains(&self.environment_intensity) {
return Err(DocError::Invalid("render environment intensity must be finite and between 0 and 100".into()));
}
// Desired mode/format/backend may be unavailable on this host. Saving intent is not preflight.
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "UncheckedSettings")]
pub struct RenderSettings {
pub setups: BTreeMap<String, RenderSetup>,
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct UncheckedSettings {
setups: BTreeMap<String, RenderSetup>,
}
impl TryFrom<UncheckedSettings> for RenderSettings {
type Error = DocError;
fn try_from(value: UncheckedSettings) -> Result<Self> {
let settings = Self { setups: value.setups };
settings.validate()?;
Ok(settings)
}
}
impl RenderSettings {
pub fn is_empty(&self) -> bool {
self.setups.is_empty()
}
pub fn validate_name(name: &str) -> Result<()> {
if name.trim().is_empty() || name != name.trim() || name.len() > MAX_SETUP_NAME_BYTES || name.chars().any(char::is_control) {
return Err(DocError::Invalid("render setup name must be trimmed, nonempty, at most 128 bytes and contain no control characters".into()));
}
Ok(())
}
pub fn validate(&self) -> Result<()> {
if self.setups.len() > MAX_RENDER_SETUPS {
return Err(DocError::Invalid("too many render setups (maximum 32)".into()));
}
for (name, setup) in &self.setups {
Self::validate_name(name)?;
setup.validate()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
use std::sync::Arc;
#[test]
fn camera_fields_are_strict_without_changing_existing_camera_defaults() {
let mut camera = serde_json::to_value(Camera::default()).unwrap();
camera["fov_degrees"] = serde_json::json!(45);
let saved = serde_json::json!({"setups":{"Studio":{"camera":camera}}});
let mut doc = serde_json::to_value(Document::default()).unwrap();
doc["render"] = saved.clone();
assert!(Document::from_json(&doc.to_string()).is_err());
assert!(serde_json::from_value::<RenderSettings>(saved).is_err());
camera.as_object_mut().unwrap().remove("fov_degrees");
camera.as_object_mut().unwrap().remove("roll");
camera.as_object_mut().unwrap().remove("up");
let setup: RenderSetup = serde_json::from_value(serde_json::json!({"camera":camera})).unwrap();
assert_eq!(setup.camera, Camera::default());
}
#[test]
fn legacy_and_named_setups_round_trip_without_changing_other_data() {
let old = Document::new("Legacy");
let mut json = serde_json::to_value(&old).unwrap();
json["format"] = serde_json::json!("solidcraft/16");
json.as_object_mut().unwrap().remove("render");
let mut loaded = Document::from_json(&json.to_string()).unwrap();
assert!(loaded.render.is_empty());
assert_eq!(loaded.format, crate::format::format_string(crate::format::FORMAT));
// Upstream owns 19 for editable offsets; saved render intent needs 20.
for version in [17, 18, 19] {
json["format"] = serde_json::json!(crate::format::format_string(version));
assert_eq!(Document::from_json(&json.to_string()).unwrap(), loaded);
}
let before = loaded.clone();
let setup = RenderSetup {
backend: serde_json::from_value(serde_json::json!("future-backend")).unwrap(),
exposure_ev: 2.5,
mode: RenderMode::Final,
format: RenderFormat::Exr,
camera: Camera { up: solidcraft_render::UpAxis::Y, ..Default::default() },
..Default::default()
};
Arc::make_mut(&mut loaded.render).setups.insert("Studio".into(), setup);
assert!(loaded.differs_only_in_render(&before));
assert_eq!(Document::from_json(&loaded.to_json()).unwrap(), loaded);
let mut previous_draft = serde_json::to_value(&loaded).unwrap();
previous_draft["format"] = serde_json::json!("solidcraft/17");
assert_eq!(Document::from_json(&previous_draft.to_string()).unwrap(), loaded);
loaded.units = "in".into();
assert!(!loaded.differs_only_in_render(&before));
}
#[test]
fn mixed_document_changes_never_qualify_as_render_only() {
let before = Document::default();
let mut render_only = before.clone();
Arc::make_mut(&mut render_only.render).setups.insert("Studio".into(), RenderSetup::default());
assert!(render_only.differs_only_in_render(&before));
for (key, value) in [
("marker", serde_json::json!(0)),
("materials", serde_json::json!({"Body1":"Steel"})),
("visibility", serde_json::json!({"hidden_bodies":["Body1"]})),
("body_components", serde_json::json!({"Body1":1})),
("next_id", serde_json::json!(99)),
] {
let mut json = serde_json::to_value(&render_only).unwrap();
json[key] = value;
let mixed: Document = serde_json::from_value(json).unwrap();
assert!(!mixed.differs_only_in_render(&before), "{key}");
}
}
#[test]
fn oversized_output_names_the_limits_and_the_largest_size() {
let setup = RenderSetup { width: 4096, height: 4096, ..Default::default() };
let message = setup.validate().unwrap_err().to_string();
assert!(message.contains("4096 × 4096 exceeds the image limits (4096 pixels per side, 4194304 pixels in all)"), "{message}");
assert!(message.contains("the largest image at this aspect is 2048 × 2048"), "{message}");
let setup = RenderSetup { width: 7680, height: 4320, ..Default::default() };
let message = setup.validate().unwrap_err().to_string();
assert!(message.contains("the largest image at this aspect is 27"), "{message}");
assert!(RenderSetup { width: 2048, height: 2048, ..Default::default() }.validate().is_ok());
}
#[test]
fn invalid_saved_intent_is_rejected_at_deserialization() {
for patch in [
serde_json::json!({"width":0}),
serde_json::json!({"width":4097}),
serde_json::json!({"width":4096,"height":4096}),
serde_json::json!({"exposure_ev":33}),
serde_json::json!({"environment_rotation":1000001}),
serde_json::json!({"environment_intensity":-1}),
serde_json::json!({"background":[256,0,0]}),
serde_json::json!({"unknown":1}),
] {
let json = serde_json::json!({"setups":{"Studio":patch}});
assert!(serde_json::from_value::<RenderSettings>(json).is_err());
}
for name in ["", " padded", "line\nfeed"] {
assert!(RenderSettings::validate_name(name).is_err());
}
let mut settings = RenderSettings::default();
for i in 0..=MAX_RENDER_SETUPS {
settings.setups.insert(i.to_string(), RenderSetup::default());
}
assert!(serde_json::from_value::<RenderSettings>(serde_json::to_value(settings).unwrap()).is_err());
let mut setup = RenderSetup::default();
setup.camera.fov = f64::NAN;
assert!(setup.validate().is_err());
for field in ["yaw", "pitch", "roll", "distance", "fov"] {
let mut value = serde_json::to_value(RenderSetup::default()).unwrap();
value["camera"][field] = serde_json::json!(1e12);
let setup: RenderSetup = serde_json::from_value(value).unwrap();
assert!(setup.validate().is_err(), "{field}");
}
setup.camera = Camera::default();
setup.environment_intensity = f64::INFINITY;
assert!(setup.validate().is_err());
}
}
+249 -11
View File
@@ -19,6 +19,7 @@ use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector
#[path = "sheet_convert.rs"]
mod convert;
pub use convert::{bend_edge_names, convert_body};
pub(crate) use convert::{outline_loop, round_wall};
// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
// These are work limits, never geometry truncation: a larger input returns a useful error.
@@ -30,6 +31,8 @@ const MAX_FLAT_POINTS: usize = 16_384;
const MAX_FLAT_SAMPLES: usize = 65_536;
const MAX_FLAT_PIECES: usize = 4096;
const MAX_FLAT_WORK: usize = 500_000_000;
/// Pieces of flat material sharing less area than this (mm²) only touch.
const TOUCH_AREA: f64 = 1e-6;
/// Starts the warning a fold gives when its `fixed` point touches no material: a recompute keeps
/// the side the point lies on, and the `sheet.fold` command refuses such a point (#263).
@@ -446,6 +449,10 @@ pub struct SheetFlange {
/// instead of a rectangle `leg` long.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<Vec<Vec2>>,
/// A converted face's panel as drawn: `shape` with arcs where the face's edges are round
/// (flat coordinates, counter-clockwise).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exact: Option<Loop2>,
/// Which feature made it: `F<feature>.<k>` (persistent edge names).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
@@ -485,6 +492,12 @@ pub struct SheetBody {
/// Perforation fields (#732), flat coordinates; their holes are computed on demand and never cut into the B-rep.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub perforations: Vec<SheetPerforation>,
/// Regions (flat coordinates) that text cut or machined, letters with their counters as
/// holes: a record of where the sheet's text went, so that its DXF can write the letters on
/// layers of their own (see [`SheetBody::letters`]). The cut-outs and pockets themselves are
/// in `holes` and `pockets`. Derived from feature history, like `pockets`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<Region2>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
@@ -842,6 +855,9 @@ impl SheetBody {
/// Flat region of a flange's panel.
pub fn panel(&self, f: &SheetFlange) -> Region2 {
if let Some(lp) = &f.exact {
return Region2 { outer: lp.ccw(), holes: Vec::new() };
}
if let Some(sh) = &f.shape {
return Region2 { outer: Loop2::polygon(sh).ccw(), holes: Vec::new() };
}
@@ -1592,6 +1608,26 @@ impl SheetBody {
Ok(through.unwrap_or_default())
}
/// The text the sheet carries: what [`SheetBody::labels`] records, ready to tell whether a
/// loop of the flat pattern is a letter.
pub fn letters(&self) -> Letters {
let regions = self
.labels
.iter()
.map(|r| {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for p in r.outer.polyline(1e-3) {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
}
// A polyline of 0.001 mm chords is within 0.001 mm of the curve.
let pad = Vec2::new(0.01, 0.01);
(lo - pad, hi + pad, std::iter::once(&r.outer).chain(&r.holes).flat_map(|lp| lp.segs.iter().copied()).collect())
})
.collect();
Letters { regions }
}
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
let material = self.flat_regions()?;
@@ -1662,6 +1698,84 @@ impl SheetBody {
let regions = if cuts.is_empty() { regions } else { material_boolean(&regions, &cuts, kernel::BoolOp::Cut, &mut work)? };
Ok(regions)
}
/// The pieces of flat material that a cut parted from the base: `after` is this sheet's
/// [`Self::flat_material`] after the cut, `before` the sheet before it. A piece is parted when
/// it holds none of the base panel, lies in a piece that held some before the cut, and no
/// other piece surrounds it: a flange cut off along its bend (with any flange on it), the
/// part of a ring's inside that lies on a flange's edge, the tip of a flange beyond a cut
/// through a closed corner. A ring's island and a letter's counter, inside other material,
/// are not counted; nor are pieces an earlier cut parted, nor anything once no piece holds
/// the base.
pub(crate) fn parted_from_base(&self, after: &[Region2], before: &SheetBody) -> Result<Vec<Region2>> {
if after.len() < 2 {
return Ok(Vec::new());
}
let mut work = FlatWork::new();
work.charge(after.len().saturating_mul(after.len()))?;
let base = [planar_region(&self.base)?];
let holds_base = |piece: &Region2, work: &mut FlatWork| -> Result<bool> {
let common = material_boolean(std::slice::from_ref(piece), &base, kernel::BoolOp::Intersect, work)?;
Ok(common.iter().map(Region2::area).sum::<f64>() > TOUCH_AREA)
};
// The cheap test first: a piece inside another's outline is an island, whatever it holds.
let outlines: Vec<(Vec<Vec2>, Vec2, Vec2)> = after
.iter()
.map(|r| {
let pts = r.outer.polyline(1e-3);
let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
(pts, lo, hi)
})
.collect();
let (mut open, mut islands) = (Vec::new(), Vec::new());
for (i, piece) in after.iter().enumerate() {
let p = piece.interior_point();
let inside = |(pts, lo, hi): &(Vec<Vec2>, Vec2, Vec2)| (lo.x..=hi.x).contains(&p.x) && (lo.y..=hi.y).contains(&p.y) && point_in(pts, p);
if outlines.iter().enumerate().any(|(j, o)| j != i && inside(o)) {
islands.push(piece);
} else {
open.push(piece);
}
}
let (mut joined, mut loose) = (false, Vec::new());
for piece in open {
if holds_base(piece, &mut work)? {
joined = true;
} else {
loose.push(piece);
}
}
if loose.is_empty() {
return Ok(Vec::new());
}
// The base may be an island itself (a cut all round it).
for piece in islands {
if joined {
break;
}
joined = holds_base(piece, &mut work)?;
}
if !joined {
return Ok(Vec::new());
}
// A cut only takes material away: each piece lies in the piece it was part of.
let mut held = Vec::new();
for piece in before.flat_material()? {
if holds_base(&piece, &mut work)? {
held.push(piece);
}
}
Ok(loose
.into_iter()
.filter(|piece| {
let p = piece.interior_point();
held.iter().any(|h| h.contains(p))
})
.cloned()
.collect())
}
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
@@ -2167,6 +2281,7 @@ impl SheetBody {
radius,
leg: leg.max(0.0),
shape: None,
exact: None,
tag: String::new(),
ext: (0.0, 0.0),
slope: (0.0, 0.0),
@@ -2192,7 +2307,7 @@ impl SheetBody {
/// such end left as it is.
fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool)) -> Vec<String> {
let Some(f) = self.flanges.get(i).cloned() else { return Vec::new() };
if f.parent.is_some() || f.shape.is_some() {
if f.parent.is_some() {
return Vec::new();
}
let (width, depth) = (self.bend_relief.0, self.bend_relief.1.max(0.0));
@@ -2601,32 +2716,42 @@ impl SheetBody {
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
let corners = self.corners(made);
let mut notes = Vec::new();
let at = |s: &SheetBody, c: Vec2| {
let w = apply_point(&s.frame_mat(), v3(c));
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
};
let mut closed = Vec::with_capacity(corners.len());
for k in &corners {
match self.close_corner(k) {
Ok(()) => closed.push(*k),
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c))),
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))),
}
}
notes.extend(self.relieve_corners(&corners, &closed));
notes
}
/// A point of the flat frame, in the part's coordinates, for a note.
fn shown(&self, c: Vec2) -> String {
let w = apply_point(&self.frame_mat(), v3(c));
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
}
/// Cut the rule's relief at each of `corners`, or tear those of them that are `closed`.
/// Returns a note for every corner without its relief or not torn.
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner]) -> Vec<String> {
let mut notes = Vec::new();
match self.relief {
ReliefShape::Notch => {}
// The reliefs come last: they move the bends' ends, which is how the corners were found.
ReliefShape::Round | ReliefShape::Rectangular => {
for k in &corners {
if let Err(why) = self.relieve_corner(k, &corners) {
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
for k in corners {
if let Err(why) = self.relieve_corner(k, corners) {
notes.push(format!("no corner relief at {}: {why}", self.shown(k.c)));
}
}
}
// A tear cuts to the flanges' closed ends, so a corner left open gets none.
ReliefShape::Tear => {
for k in &closed {
for k in closed {
if let Err(why) = self.tear_corner(k) {
notes.push(format!("the corner at {} keeps its notch: {why}", at(self, k.c)));
notes.push(format!("the corner at {} keeps its notch: {why}", self.shown(k.c)));
}
}
}
@@ -2899,6 +3024,7 @@ impl SheetBody {
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
exact: None,
tag: String::new(),
});
Ok(self.flanges.len() - 1)
@@ -3033,6 +3159,7 @@ impl SheetBody {
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
exact: None,
tag: String::new(),
};
let ba = self.allowance(&probe);
@@ -3097,6 +3224,7 @@ impl SheetBody {
Some(j) => {
if let Some(f) = self.flanges.get_mut(j) {
f.shape = Some(ccw(fixed_part.clone()));
f.exact = None;
}
}
}
@@ -3113,6 +3241,7 @@ impl SheetBody {
slope: (0.0, 0.0),
tear: (false, false),
shape: Some(ccw(moving)),
exact: None,
tag: String::new(),
});
let i = self.flanges.len() - 1;
@@ -3238,6 +3367,7 @@ impl SheetBody {
Some(j) => {
if let Some(f) = self.flanges.get_mut(j) {
f.shape = Some(ccw(fixed));
f.exact = None;
}
}
}
@@ -3256,6 +3386,7 @@ impl SheetBody {
slope: (0.0, 0.0),
tear: (false, false),
shape: Some(ccw(moving)),
exact: None,
tag: String::new(),
});
}
@@ -3938,6 +4069,30 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
})
}
/// Whether every segment of `lp` lies on one of `curves` (its ends and its middle, within
/// 0.0001 mm): the loop is made of those curves, or of parts of them.
pub fn loop_lies_on(lp: &Loop2, curves: &[Seg2]) -> bool {
const TOL: f64 = 1e-4;
let near = |p: Vec2| curves.iter().any(|c| c.point_at(c.closest_param(p)).dist(p) <= TOL);
!lp.segs.is_empty() && lp.segs.iter().all(|g| near(g.start()) && near(g.mid()) && near(g.end()))
}
/// Tells which loops of a sheet's flat pattern are letters (see [`SheetBody::letters`]).
#[derive(Clone, Debug)]
pub struct Letters {
/// Each text region's corners (lowest, highest) and the segments of its outlines.
regions: Vec<(Vec2, Vec2, Vec<Seg2>)>,
}
impl Letters {
/// Whether `lp` is made of the outlines of text the sheet carries: a letter, or one of its
/// counters.
pub fn is_letter(&self, lp: &Loop2) -> bool {
let Some(p) = lp.segs.first().map(Seg2::start) else { return false };
self.regions.iter().any(|(lo, hi, curves)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && loop_lies_on(lp, curves))
}
}
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
@@ -3959,6 +4114,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4016,6 +4172,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
pockets: Vec::new(),
bores: Vec::new(),
perforations: Vec::new(),
labels: Vec::new(),
flat: false,
gap: rv.gap,
relief: rv.corner_relief_shape,
@@ -4055,6 +4212,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
slope: (0.0, 0.0),
tear: (false, false),
shape: None,
exact: None,
tag: String::new(),
};
let ba = sheet.allowance(&f);
@@ -4159,6 +4317,41 @@ mod tests {
kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN)
}
#[test]
fn loops_lie_on_the_curves_they_are_made_of() {
let circle = Loop2::circle(Vec2::new(5.0, 5.0), 3.0);
let curves: Vec<Seg2> = circle.segs.clone();
// The loop itself, started elsewhere and run the other way, and a loop of its halves.
assert!(loop_lies_on(&circle, &curves));
assert!(loop_lies_on(&Loop2::circle_from(Vec2::new(5.0, 5.0), 3.0, 1.0).reversed(), &curves));
let halves: Vec<Seg2> = circle.segs.iter().flat_map(|g| [g.split_half().0, g.split_half().1]).collect();
assert!(loop_lies_on(&Loop2 { segs: halves }, &curves));
// Another circle, a square and an empty loop do not.
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 3.1), &curves));
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 2.0), &curves));
assert!(!loop_lies_on(&rect(Vec2::ZERO, Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y).outer, &curves));
assert!(!loop_lies_on(&Loop2 { segs: Vec::new() }, &curves));
// A loop only partly on them (a letter across the sheet's edge) is not on them.
let notch = Loop2 { segs: vec![circle.segs[0], Seg2::Line { a: circle.segs[0].end(), b: Vec2::new(50.0, 50.0) }] };
assert!(!loop_lies_on(&notch, &curves));
}
#[test]
fn a_sheet_knows_the_letters_it_carries() {
// A recorded text region with a counter: its outline and its counter are letters, the same
// circle elsewhere, a loop near it that is another size and a loop far away are not.
let mut s = plate(100.0, 60.0);
let counter = Loop2::circle(Vec2::new(20.0, 20.0), 2.0).reversed();
s.labels.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 5.0), holes: vec![counter.clone()] });
let letters = s.letters();
assert!(letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
assert!(letters.is_letter(&counter.reversed()));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 4.0)));
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(70.0, 20.0), 5.0)));
// A sheet that carries none has no letters.
assert!(!plate(100.0, 60.0).letters().is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
}
#[test]
fn edge_flange_matches_fusion() {
// Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge.
@@ -7276,4 +7469,49 @@ mod tests {
holds_the_flat_material(&s, &what);
}
}
/// The 100 x 60 plate with a 20 mm flange on its y = 0 edge (2.5 mm, R 2.5, K 0.44): in the
/// flat the base starts at y = t + R = 5, the bend's zone is (π/2)·(R + K·t) long and the
/// flange's panel 20 − 5, so its far edge lies at y = 5 − (π/2)·3.6 − 15.
#[test]
fn a_cut_names_the_pieces_it_parts_from_the_base_and_no_others() {
use std::f64::consts::{FRAC_PI_2, PI};
let far = 5.0 - FRAC_PI_2 * (2.5 + 0.44 * 2.5) - 15.0;
let mut s = plate(100.0, 60.0);
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
let r = |x0: f64, y0: f64, x1: f64, y1: f64| rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1));
let ring = |c: Vec2, ro: f64, ri: f64| Region2 { outer: Loop2::circle(c, ro), holes: vec![Loop2::circle(c, ri)] };
let parted = |before: &SheetBody, holes: &[Region2]| {
let mut after = before.clone();
after.holes.extend_from_slice(holes);
after.parted_from_base(&after.flat_material().unwrap(), before).unwrap()
};
// A slot across the flange beside its bend parts the rest of the flange: 100 × (−3 − far).
let slot = r(-5.0, -3.0, 105.0, -1.0);
let p = parted(&s, std::slice::from_ref(&slot));
assert!(p.len() == 1 && (p[0].area() - 100.0 * (-3.0 - far)).abs() < 1e-6, "{p:?}");
// So does a ring on the flange's far edge: the half of its inside on the flange, π·2²/2.
let p = parted(&s, &[ring(Vec2::new(50.0, far), 4.0, 2.0)]);
assert!(p.len() == 1 && (p[0].area() - 2.0 * PI).abs() < 1e-6, "{p:?}");
// A ring's island, on the base or on the flange, has material round it; a strip across the
// whole sheet leaves two halves that each keep their part of the base. None is named.
for holes in [ring(Vec2::new(50.0, 30.0), 10.0, 5.0), ring(Vec2::new(50.0, -9.0), 4.0, 2.0), r(45.0, -30.0, 55.0, 70.0)] {
assert!(parted(&s, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
}
// Pieces an earlier cut parted are not named again, nor when a later cut splits them.
let mut cut = s.clone();
cut.holes.push(slot);
for holes in [Region2 { outer: Loop2::circle(Vec2::new(30.0, 30.0), 4.0), holes: vec![] }, r(45.0, -30.0, 55.0, -2.0)] {
assert!(parted(&cut, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
}
// Nothing is named once no piece holds the base: a cut that takes the base and both bends
// of a sheet with flanges on two sides leaves the two flanges.
let mut two = plate(100.0, 60.0);
two.add_flanges(&[Vec3::new(50.0, 0.0, 2.5), Vec3::new(50.0, 60.0, 2.5)], 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
let mut after = two.clone();
after.holes.push(r(-5.0, -3.0, 105.0, 63.0));
let material = after.flat_material().unwrap();
assert_eq!(material.len(), 2);
assert!(after.parted_from_base(&material, &two).unwrap().is_empty());
}
}
+675 -57
View File
@@ -11,20 +11,49 @@
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
//! gap from the plane halfway between the two.
//!
//! The bends on the fixed face are finished as the flanges of one edge flange feature are: where
//! two meet at a corner of it, their walls are closed with the rule's gap and the corner gets the
//! rule's corner relief (round, rectangular, a tear, or the notch the bends leave), and where one
//! ends beside more of the fixed face, the rule's bend relief is cut there. A wall that stands on
//! part of an edge of the fixed face (its outside in one plane with the plate's edge, which runs
//! on past it a strip no taller than the bend's setback) bends only where it stands: the strip is
//! the fixed face's own edge.
//!
//! A bend can also be a chamfer: a flat strip between two faces, as a model without bend radii
//! draws a bend (the chord of its arc). Picked in its middle, the strip is no face of the sheet:
//! the faces on either side are taken out to where their planes meet, and that line is the
//! bend's virtual sharp, bent with the rule's radius at the angle between the two faces.
//!
//! A bend modelled with its radius, a cylinder between two planar faces tangent to it along its
//! length, is picked anywhere on its cylinder, on either skin, and taken the same way: the two
//! faces out to their virtual sharp, bent there with the rule's radius and K-factor. The
//! modelled radius gives way to the rule's, as a sharp edge's does: the flat is measured from
//! the virtual sharps, which don't depend on it.
//!
//! The result is the model every sheet metal feature builds ([`SheetBody`]): the fixed face is
//! the base panel and every other panel a flange on its parent's edge, its outline taken through
//! the inverse of its fold. Unfold, the flat pattern, the bend table and cuts then work as on a
//! sheet made from flanges.
//!
//! Not supported yet: curved faces (rolled sheet, a filleted edge as a bend), faces with curved
//! edges or holes, loops of bends, two faces that meet along more than one edge.
//! A face's outline is read exactly, as a plate's is: where it runs along a round wall standing
//! square on the face (a rounded corner, a tooth) it is an arc of the wall's circle, and its
//! holes and slots are cut-outs of the sheet where they lie in its panel. Its pockets (an
//! engraved label: an inner loop whose walls end on flat floors, at most half the sheet deep)
//! are pockets of the sheet from that skin, round their islands; on a shell the faces of the
//! other skin are read for theirs.
//!
//! Not supported yet: curved faces other than bends (rolled sheet), holes and pockets that reach
//! into a bend, pockets deeper than half the sheet or with a step or a hole in their floor, round
//! edges that end at a bend or a cut edge, loops of bends, two faces that meet along more than
//! one edge, a wall on part of an edge of a face other than the fixed one.
use std::collections::BTreeMap;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Vec2, Vec3};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_kernel::{Body, FaceSurface, face_surface};
use super::{RuleValues, SheetBody, SheetFlange, base_flange, polys_cross, signed_area, snap};
use super::{FlatWork, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
const MAX_BENDS: usize = 1000;
@@ -38,11 +67,16 @@ fn at(p: Vec3) -> String {
}
/// A planar face of the body: its outward normal and its outline, counter-clockwise about the
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`.
/// normal. Edge `i` runs from corner `i` to the next one, along face `across[i]`: straight, or an
/// arc where that face is a round wall (`round[i]`: the circle's centre and the arc's middle).
/// `holes`: its inner loops, in `plane`, each with the faces of its walls.
struct Face {
n: Vec3,
pts: Vec<Vec3>,
across: Vec<Option<usize>>,
round: Vec<Option<(Vec3, Vec3)>>,
plane: Plane,
holes: Vec<(Loop2, Vec<usize>)>,
}
/// A face of the sheet: the panel it hangs off (none for the fixed face), and the bend between
@@ -68,38 +102,168 @@ fn turn<T>(per_edge: &mut [T]) {
}
}
/// Face `f` of the body as a panel outline. Only a planar face bounded by one loop of straight
/// edges can be one.
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, f) else {
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
};
// Its edges, each with the face on its other side.
let mut edges = Vec::new();
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
if !faces.iter().any(|g| *g as usize == f) {
/// The circle (centre in `plane` coordinates, radius) of face `f` when it is a round wall
/// standing square on the plane: a cylinder about the plane's normal `n`.
pub(crate) fn round_wall(body: &Body, plane: &Plane, n: Vec3, f: u32) -> Option<(Vec2, f64)> {
match face_surface(body, f as usize)? {
FaceSurface::Cylinder { origin, axis, radius } if axis.cross(n).len() < 1e-6 => Some((plane.to_local(origin), radius)),
_ => None,
}
}
/// A loop of points on a face, each with the B-rep face across the loop from it to the next
/// point (the wall it runs along there), as segments: each run along one round wall (`wall`
/// gives a face's circle) is an arc of that circle, the rest stays straight. Coaxial cylinders of
/// one radius are one wall, however many faces it comes in (many systems write a round hole's
/// wall as two half-cylinders), so such a hole is one circle. `eps`: how far off its circle a
/// point may be.
pub(crate) fn outline_loop(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Loop2 {
Loop2 { segs: outline_segs(pts, wall, eps).into_iter().map(|(seg, _)| seg).collect() }
}
/// [`outline_loop`], each segment with its wall (an arc's: that of its first point).
fn outline_segs(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Vec<(Seg2, Option<u32>)> {
let m = pts.len();
let point = |i: usize| pts.get(i % m).map(|x| x.0).unwrap_or_default();
let face = |i: usize| pts.get(i % m).and_then(|x| x.1);
// The circle each segment runs along: its wall's, when both its ends lie on it.
let circles: Vec<Option<(Vec2, f64)>> = (0..m)
.map(|i| {
let (c, r) = wall(pts.get(i)?.1?)?;
((point(i).dist(c) - r).abs() <= eps && (point(i + 1).dist(c) - r).abs() <= eps).then_some((c, r))
})
.collect();
let circle = |i: usize| circles.get(i % m).copied().flatten();
let same = |i: usize, (c, r): (Vec2, f64)| circle(i).is_some_and(|(d, s)| d.dist(c) <= eps && (s - r).abs() <= eps);
// Start where no arc runs on across the start (anywhere when the whole loop is one circle).
let start = (0..m).find(|i| circle(*i + m - 1).is_none_or(|c| !same(*i, c))).unwrap_or(0);
let mut segs = Vec::new();
let mut i = 0;
while i < m {
let k = start + i;
let Some((c, r)) = circle(k) else {
segs.push((Seg2::Line { a: point(k), b: point(k + 1) }, face(k)));
i += 1;
continue;
};
let mut j = i + 1;
while j < m && same(start + j, (c, r)) {
j += 1;
}
let sweep: f64 = (i..j)
.map(|s| {
let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle();
(d + PI).rem_euclid(TAU) - PI
})
.sum();
// Arcs of up to half a turn each.
let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4);
let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64);
segs.extend((0..pieces).map(|p| (Seg2::Arc { center: c, radius: r, start: a0 + step * p as f64, sweep: step }, face(k))));
i = j;
}
segs
}
/// `segs` (a closed loop) with lines that run on in one line joined: a slot's side in two faces
/// is one line.
fn joined_lines(segs: impl IntoIterator<Item = Seg2>, tol: f64) -> Vec<Seg2> {
let on = |p: Vec2, a: Vec2, b: Vec2| p.extend(0.0).dist_to_segment(a.extend(0.0), b.extend(0.0)) <= tol;
let mut out: Vec<Seg2> = Vec::new();
for seg in segs {
if let (Some(Seg2::Line { a, b }), Seg2::Line { b: c, .. }) = (out.last_mut(), seg)
&& on(*b, *a, c)
{
*b = c;
continue;
}
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
if p.dist(q) <= tol || line.iter().any(|x| x.dist_to_segment(p, q) > tol) {
return err("not supported yet: faces with curved edges in a converted sheet");
out.push(seg);
}
// Across the loop's start.
if let (true, Some(Seg2::Line { a, b }), Some(Seg2::Line { b: c, .. })) = (out.len() > 2, out.last().copied(), out.first().copied())
&& on(b, a, c)
{
out.pop();
if let Some(Seg2::Line { a: first, .. }) = out.first_mut() {
*first = a;
}
edges.push((p, q, faces.iter().map(|g| *g as usize).find(|g| *g != f)));
}
// Chained head to tail from the first one, they close one loop.
let Some((start, mut end, across)) = edges.pop() else { return err("a face of the body has no edges") };
let (mut pts, mut tags) = (vec![start], vec![across]);
while end.dist(start) > tol {
let Some(k) = edges.iter().position(|e| e.0.dist(end) <= tol || e.1.dist(end) <= tol) else {
return err("the outline of a face of the body does not close");
};
let (p, q, across) = edges.swap_remove(k);
pts.push(end);
tags.push(across);
end = if p.dist(end) <= tol { q } else { p };
out
}
/// Face `f` of the body as a panel outline. Only a planar face can be one. Its loops are read
/// exactly, as a plate's are (an arc where one runs along a round wall standing square on the
/// face): the outer one is the outline, the others are its holes.
fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
let Some(FaceSurface::Plane { normal: n, point }) = face_surface(b, f) else {
return err("not supported yet: curved faces in a converted sheet (only planar faces convert)");
};
let plane = Plane::from_normal(point, n).ok_or_else(|| DocError::Invalid("a face of the body has no plane".into()))?;
// Its edges, each with the face on its other side.
let mut edges: Vec<(&[Vec3], Option<usize>)> = mesh
.edges
.iter()
.zip(&mesh.edge_faces)
.filter(|(line, faces)| line.len() >= 2 && faces.iter().any(|g| *g as usize == f))
.map(|(line, faces)| (line.as_slice(), faces.iter().map(|g| *g as usize).find(|g| *g != f)))
.collect();
// Chained head to tail, they close the outline and its holes: each point with the face the
// loop runs along from it to the next point.
let mut loops: Vec<Vec<(Vec3, Option<usize>)>> = Vec::new();
while let Some((line, across)) = edges.pop() {
let (Some(&start), Some(&last)) = (line.first(), line.last()) else { continue };
let mut lp: Vec<(Vec3, Option<usize>)> = line.iter().take(line.len().saturating_sub(1)).map(|p| (*p, across)).collect();
let mut end = last;
while end.dist(start) > tol {
let meets = |p: Option<&Vec3>| p.is_some_and(|p| p.dist(end) <= tol);
let Some(k) = edges.iter().position(|(l, _)| meets(l.first()) || meets(l.last())) else {
return err("the outline of a face of the body does not close");
};
let (l, g) = edges.swap_remove(k);
let run: Vec<Vec3> = if meets(l.first()) { l.to_vec() } else { l.iter().rev().copied().collect() };
lp.extend(run.iter().take(run.len().saturating_sub(1)).map(|p| (*p, g)));
end = run.last().copied().unwrap_or(start);
}
loops.push(lp);
}
if !edges.is_empty() {
return err("not supported yet: faces with holes in a converted sheet (cut them after converting)");
let mut walls = std::collections::HashMap::new();
let mut wall = |g: u32| *walls.entry(g).or_insert_with(|| round_wall(b, &plane, n, g));
// Each loop with the faces of its walls.
let mut shaped: Vec<(Vec<(Seg2, Option<u32>)>, Vec<usize>)> = loops
.iter()
.map(|lp| {
let pts: Vec<(Vec2, Option<u32>)> = lp.iter().map(|(p, g)| (plane.to_local(*p), g.and_then(|g| u32::try_from(g).ok()))).collect();
let mut walls: Vec<usize> = lp.iter().filter_map(|(_, g)| *g).collect();
walls.sort_unstable();
walls.dedup();
(outline_segs(&pts, &mut wall, tol * 10.0), walls)
})
.collect();
let area = |segs: &[(Seg2, Option<u32>)]| segs.iter().map(|(seg, _)| seg.area_term()).sum::<f64>().abs();
shaped.sort_by(|x, y| area(&y.0).total_cmp(&area(&x.0)));
let mut it = shaped.into_iter();
let Some((outer, _)) = it.next() else { return err("a face of the body has no edges") };
let holes = it.map(|(segs, walls)| (Loop2 { segs: joined_lines(segs.into_iter().map(|(seg, _)| seg), tol) }, walls)).collect();
// The outline's corners, where its segments start (an arc's in quarter turns at most, so that
// the corners alone still make an outline), each with the face along it.
let world = |v: Vec2| plane.to_world(v);
let (mut pts, mut tags, mut round) = (Vec::new(), Vec::new(), Vec::new());
for (seg, g) in outer {
let g = g.map(|g| g as usize);
if let Seg2::Arc { center, radius, start, sweep } = seg {
let k = ((sweep.abs() / FRAC_PI_2 - 1e-9).ceil() as usize).clamp(1, 8);
let step = sweep / k as f64;
for p in 0..k {
let a = start + step * p as f64;
pts.push(world(center + Vec2::from_angle(a) * radius));
tags.push(g);
round.push(Some((world(center), world(center + Vec2::from_angle(a + step / 2.0) * radius))));
}
} else {
pts.push(world(seg.start()));
tags.push(g);
round.push(None);
}
}
// A straight run along one neighbour is one edge, however the body's edges divide it (a
// shell's outer corners are divided at the height of its floor).
@@ -107,31 +271,187 @@ fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
let corner = |i: usize| -> bool {
let before = (i + m - 1) % m;
let (Some(&a), Some(&p), Some(&b)) = (pts.get(before), pts.get(i), pts.get((i + 1) % m)) else { return true };
tags.get(before) != tags.get(i) || p.dist_to_segment(a, b) > tol
let arc = |k: usize| round.get(k).is_some_and(Option::is_some);
tags.get(before) != tags.get(i) || arc(before) || arc(i) || p.dist_to_segment(a, b) > tol
};
let keep: Vec<bool> = (0..m).map(corner).collect();
let (mut pts, mut tags): (Vec<Vec3>, Vec<Option<usize>>) = pts.into_iter().zip(tags).zip(keep).filter(|(_, kept)| *kept).map(|(x, _)| x).unzip();
let keep: Vec<usize> = (0..m).filter(|i| corner(*i)).collect();
let mut pts: Vec<Vec3> = keep.iter().filter_map(|i| pts.get(*i).copied()).collect();
let mut tags: Vec<Option<usize>> = keep.iter().filter_map(|i| tags.get(*i).copied()).collect();
let mut round: Vec<Option<(Vec3, Vec3)>> = keep.iter().filter_map(|i| round.get(*i).copied()).collect();
if area_about(&pts, n) < 0.0 {
pts.reverse();
turn(&mut tags);
turn(&mut round);
}
Ok(Face { n, pts, across: tags })
Ok(Face { n, pts, across: tags, round, plane, holes })
}
/// The face straight through the material behind planar face `f`, when it is parallel to it,
/// and how far behind it lies.
fn behind(b: &Body, mesh: &Mesh, f: usize) -> Option<(usize, f64)> {
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, f) else { return None };
// From the middle of the face's largest triangle.
// The face most of it looks through to, from points in its largest triangles: a few of them
// may look at the floor of a label engraved in the other skin.
let area = |t: &[Vec3; 3]| (t[1] - t[0]).cross(t[2] - t[0]).len();
let tri = mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t));
let [p, q, r] = tri.max_by(|x, y| area(x).total_cmp(&area(y)))?;
let (_, hit) = mesh.raycast((p + q + r) * (1.0 / 3.0), n * -1.0)?;
let g = *mesh.tri_face.get(hit)? as usize;
let mut tris: Vec<[Vec3; 3]> =
mesh.triangles.iter().zip(&mesh.tri_face).filter(|(_, g)| **g as usize == f).filter_map(|(t, _)| mesh.tri(t)).collect();
tris.sort_by(|x, y| area(y).total_cmp(&area(x)));
let mut seen: Vec<(usize, f64)> = Vec::new();
for tri in tris.iter().take(8) {
let middle = (tri[0] + tri[1] + tri[2]) * (1.0 / 3.0);
for from in std::iter::once(middle).chain(tri.iter().map(|v| (middle + *v) * 0.5)) {
let Some(g) = mesh.raycast(from, n * -1.0).and_then(|(_, hit)| mesh.tri_face.get(hit)).map(|g| *g as usize) else { continue };
match seen.iter_mut().find(|(h, _)| *h == g) {
Some(entry) => entry.1 += area(tri),
None => seen.push((g, area(tri))),
}
}
}
let g = seen.iter().max_by(|x, y| x.1.total_cmp(&y.1))?.0;
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, g) else { return None };
(normal.dot(n) < -1.0 + 1e-9).then_some((g, (o - point).dot(n)))
}
/// A chamfer or a bend's cylinder picked as a bend: the face `strip` between the planar faces
/// `a` and `b`, which meet in that bend at the line where their planes cross.
#[derive(Clone, Copy)]
struct Chamfer {
strip: usize,
a: usize,
b: usize,
}
/// The chamfer or bend cylinder a bend point picks. A bend cylinder ([`bend_cylinder`]) is
/// picked by a point anywhere on it (within `near`, the cylinder being drawn in chords). A
/// chamfer by a point on the middle half of a planar face that runs between two planar faces
/// along two parallel edges, the two faces' planes crossing in a line parallel to them and the
/// strip facing between the two (as the chord of a bend's arc does). A point on any other face,
/// or near a chamfer's edge, picks an edge as before.
fn chamfer_at(mb: &ModelBody, mesh: &Mesh, p: Vec3, tol: f64, near: f64) -> Option<Chamfer> {
let b = &mb.body;
let (d, f) = crate::appearance::nearest_face(mb, p)?;
if d <= near
&& let Some(c) = bend_cylinder(b, mesh, f, tol)
{
return Some(c);
}
if d > tol {
return None;
}
let strip = outline(b, mesh, f, tol).ok()?;
let plane = |g: usize| match face_surface(b, g) {
Some(FaceSurface::Plane { normal, .. }) => Some(normal),
_ => None,
};
let m = strip.pts.len();
let edge = |i: usize| -> Option<(Vec3, Vec3, usize)> {
let (p, q) = (*strip.pts.get(i)?, *strip.pts.get((i + 1) % m)?);
Some((p, (q - p).normalized()?, strip.across.get(i).copied().flatten()?))
};
let mut sides = Vec::new();
for i in 0..m {
for j in i + 1..m {
let (Some((pa, da, a)), Some((pb, db, bb))) = (edge(i), edge(j)) else { continue };
let (Some(na), Some(nb)) = (plane(a), plane(bb)) else { continue };
let Some(sharp) = na.cross(nb).normalized() else { continue };
let lined_up = da.dot(db) < -1.0 + 1e-7 && sharp.dot(da).abs() > 1.0 - 1e-7;
if a != bb && a != f && bb != f && lined_up && strip.n.dot(na) > 1e-9 && strip.n.dot(nb) > 1e-9 {
sides.push((pa, pb, da, a, bb));
}
}
}
let &[(pa, pb, d, a, bb)] = sides.as_slice() else { return None };
// Its middle half: a quarter of its width or more from either edge.
let off = |o: Vec3| (p - o - d * (p - o).dot(d)).len();
let width = off(pa) + off(pb);
(off(pa) >= width / 4.0 && off(pb) >= width / 4.0).then_some(Chamfer { strip: f, a, b: bb })
}
/// Face `f` as a bend modelled with its radius: a cylinder with a planar face tangent to it
/// along a straight edge parallel to its axis on either side, the two planes crossing in a line
/// (a bend between 0 and 180°). Other faces along it (the walls of a notch) don't count.
fn bend_cylinder(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Option<Chamfer> {
let Some(FaceSurface::Cylinder { origin, axis, .. }) = face_surface(b, f) else { return None };
let mut sides: Vec<(usize, Vec3)> = Vec::new();
for (line, faces) in mesh.edges.iter().zip(&mesh.edge_faces) {
let g = match *faces.as_slice() {
[x, y] if x as usize == f => y as usize,
[x, y] if y as usize == f => x as usize,
_ => continue,
};
let (Some(&p), Some(&q)) = (line.first(), line.last()) else { continue };
let along = (q - p).normalized().is_some_and(|d| d.cross(axis).len() <= 1e-6);
if g == f || !along || line.iter().any(|x| x.dist_to_segment(p, q) > tol) || sides.iter().any(|(h, _)| *h == g) {
continue;
}
// Tangent: the plane's normal is the cylinder's there, square to the axis.
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, g) else { continue };
let radial = (p - origin) - axis * (p - origin).dot(axis);
if radial.normalized().is_some_and(|out| n.dot(out).abs() >= 1.0 - 1e-6) {
sides.push((g, n));
}
}
let &[(a, na), (bb, nb)] = sides.as_slice() else { return None };
na.cross(nb).normalized().map(|_| Chamfer { strip: f, a, b: bb })
}
/// The cylinder of bend `c` on a shell's other skin: the bend cylinder between the faces
/// straight behind `c`'s two sides, so that a bend picked on either skin is that bend on both.
fn other_skin(b: &Body, mesh: &Mesh, c: &Chamfer, tol: f64) -> Option<Chamfer> {
let (x, y) = (behind(b, mesh, c.a)?.0, behind(b, mesh, c.b)?.0);
let beside_x = mesh.edge_faces.iter().filter(|faces| faces.contains(&(x as u32))).flatten().map(|g| *g as usize);
beside_x
.filter(|g| *g != x && *g != c.strip)
.find_map(|g| bend_cylinder(b, mesh, g, tol).filter(|o| (o.a, o.b) == (x, y) || (o.a, o.b) == (y, x)))
}
/// Face `g`'s outline with each picked chamfer along it taken out: the edge along the chamfer
/// moves to where `g`'s plane meets the plane of the face across the chamfer (its corners
/// sliding along the edges before and after it), and runs along that face.
fn without_chamfers(b: &Body, mut face: Face, g: usize, chamfers: &[Chamfer], tol: f64) -> Result<Face> {
for c in chamfers {
let other = if c.a == g {
c.b
} else if c.b == g {
c.a
} else {
continue;
};
let Some(FaceSurface::Plane { normal: n, point: o }) = face_surface(b, other) else {
return err("the face across a chamfer picked as a bend is not flat");
};
let m = face.pts.len();
let along: Vec<usize> = (0..m).filter(|i| face.across.get(*i).copied().flatten() == Some(c.strip)).collect();
let &[k] = along.as_slice() else {
return err("not supported yet: a chamfer picked as a bend that runs along a face more than once");
};
// Its corners slide along the edges before and after it: those must be straight.
if [k + m - 1, k + 1].iter().any(|i| face.round.get(i % m).is_some_and(Option::is_some)) {
return err("not supported yet: a round edge of a face that ends at a chamfer picked as a bend");
}
// Each end of the edge slides along the edge on its other side, out to the other plane.
let slide = |corner: usize, before: usize| -> Option<Vec3> {
let (p, q) = (*face.pts.get(corner % m)?, *face.pts.get(before % m)?);
let d = (p - q).normalized()?;
let s = (o - p).dot(n) / d.dot(n);
(s.is_finite() && s >= -tol).then_some(p + d * s)
};
let (Some(p), Some(q)) = (slide(k, k + m - 1), slide(k + 1, k + 2)) else {
return err("a face beside a chamfer picked as a bend does not reach the face across it");
};
if let Some(x) = face.pts.get_mut(k) {
*x = p;
}
if let Some(x) = face.pts.get_mut((k + 1) % m) {
*x = q;
}
if let Some(x) = face.across.get_mut(k) {
*x = Some(other);
}
}
Ok(face)
}
/// How far a panel's edge along a rip moves in, so that the panel, cut square to the sheet,
/// stays half the gap from the plane halfway between it and the panel `y` across the rip.
/// `e` is the edge's direction round panel `x`.
@@ -188,6 +508,96 @@ fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
Ok((out, from))
}
/// A wall standing on part of an edge of the fixed face: where the outline of `wall`, the face
/// across a bend from the fixed face `base` (`ids`: the two faces), leaves the bend's edge up a
/// strip no taller than the bend's setback `by`, runs back along the edge and climbs the wall's
/// end, the strip is the fixed face's own edge (a plate's edge face in one plane with the outside
/// of a wall that stands on part of it), which the setback would use up. It is cut off the wall,
/// whose bend's edge then ends where the line of the wall's end meets it, and the fixed face's
/// edge is split there: the pieces beside the wall are no bend. `bend` says whether a face is a
/// bend of the wall. Any other wall is left as it is.
fn end_at_wall(base: &mut Face, wall: &mut Face, ids: (usize, usize), by: f64, bend: &dyn Fn(usize) -> bool, tol: f64) {
let (m, n) = (wall.pts.len(), base.pts.len());
let at_wall: Vec<usize> = (0..m).filter(|i| wall.across.get(*i).copied().flatten() == Some(ids.0)).collect();
let at_base: Vec<usize> = (0..n).filter(|i| base.across.get(*i).copied().flatten() == Some(ids.1)).collect();
let (&[k], &[i]) = (at_wall.as_slice(), at_base.as_slice()) else { return };
if m < 5 {
return;
}
// Corner `j` and edge `j` (from corner `j`) counted from the bend's edge on.
let pt = |j: usize| wall.pts.get((k + j) % m).copied().unwrap_or_default();
let (p, q) = (pt(0), pt(1));
let Some(d) = (q - p).normalized() else { return };
// Up the wall from the bend's edge (the outline runs counter-clockwise about the normal), and
// along the edge.
let up = |x: Vec3| (x - p).dot(wall.n.cross(d));
let along = |x: Vec3| (x - p).dot(d);
let free = |j: usize| {
let j = (k + j) % m;
wall.round.get(j).is_some_and(Option::is_none) && wall.across.get(j).copied().flatten().is_none_or(|g| !bend(g))
};
let low = |x: Vec3| up(x) > tol && up(x) <= by + tol;
// A strip from an end of the bend's edge up to `x1`, back along the edge (`back`: the way it
// runs) to `x2`, then the wall's end up to `x3`: where the line of that end meets the edge.
let foot = |x1: Vec3, x2: Vec3, x3: Vec3, back: f64| {
let rise = up(x3) - up(x2);
(low(x1) && low(x2) && (x2 - x1).dot(d) * back > tol && rise > tol).then(|| x2 - (x3 - x2) * (up(x2) / rise))
};
// At the edge's end the strip is edges 1 and 2, the wall's end edge 3; at its start the strip
// is edges m - 1 and m - 2, the wall's end edge m - 3.
let ok = |js: [usize; 3]| js.iter().all(|j| free(*j));
let end = if ok([1, 2, 3]) { foot(pt(2), pt(3), pt(4), -1.0) } else { None };
let start = if ok([m - 1, m - 2, m - 3]) { foot(pt(m - 1), pt(m - 2), pt(m - 3), 1.0) } else { None };
let (p1, q1) = (start.unwrap_or(p), end.unwrap_or(q));
let len = along(q);
let inside = |x: Option<Vec3>| x.is_none_or(|x| along(x) > tol && along(x) < len - tol);
if (start.is_none() && end.is_none())
|| !inside(start)
|| !inside(end)
|| along(q1) - along(p1) <= tol
|| (start.is_some() && end.is_some() && m < 7)
{
return;
}
// The wall: the strips' corners and edges go, the bend's edge runs from `p1` to `q1`.
let (mut corners_out, mut edges_out) = (Vec::new(), Vec::new());
if end.is_some() {
corners_out.extend([2, 3]);
edges_out.extend([1, 2]);
}
if start.is_some() {
corners_out.extend([m - 1, m - 2]);
edges_out.extend([m - 1, m - 2]);
}
if let Some(x) = wall.pts.get_mut(k) {
*x = p1;
}
if let Some(x) = wall.pts.get_mut((k + 1) % m) {
*x = q1;
}
let kept = |j: usize, out: &[usize]| !out.contains(&((j + m - k) % m));
wall.pts = wall.pts.iter().enumerate().filter(|(j, _)| kept(*j, &corners_out)).map(|(_, x)| *x).collect();
wall.across = wall.across.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
wall.round = wall.round.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
// The fixed face's edge: the bend from `p1` to `q1` (it runs the other way along it), no bend
// beside it.
let (Some(&a), Some(&b)) = (base.pts.get(i), base.pts.get((i + 1) % n)) else { return };
let from_a = |x: Vec3| (x - a).dot(b - a);
let (x, y) = if from_a(p1) < from_a(q1) { (p1, q1) } else { (q1, p1) };
let mut pieces: Vec<(Vec3, Option<usize>)> = Vec::with_capacity(3);
if x.dist(a) > tol {
pieces.push((a, None));
}
pieces.push((if x.dist(a) > tol { x } else { a }, Some(ids.1)));
if y.dist(b) > tol {
pieces.push((y, None));
}
let count = pieces.len();
base.pts.splice(i..=i, pieces.iter().map(|(x, _)| *x));
base.across.splice(i..=i, pieces.iter().map(|(_, g)| *g));
base.round.splice(i..=i, std::iter::repeat_n(None, count));
}
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
/// bend first trimmed to the end of the bend zone.
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
@@ -210,20 +620,60 @@ fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
}
}
/// A panel's trimmed outline as drawn: an edge that was a round edge of the face is an arc again
/// (`round`, per edge of the untrimmed outline `pts`: the circle's centre and the arc's middle),
/// pieces of one circle in a row one arc of up to half a turn. None when the face has no round
/// edges. A round edge keeps both its ends: one that a bend's setback or a cut edge would move is
/// not supported yet.
fn drawn(trimmed: &[Vec2], from: &[Option<usize>], pts: &[Vec2], round: &[Option<(Vec2, Vec2)>]) -> Result<Option<Loop2>> {
if round.iter().all(Option::is_none) {
return Ok(None);
}
let (m, n) = (trimmed.len(), pts.len());
let mut segs: Vec<Seg2> = Vec::with_capacity(m);
for j in 0..m {
let (Some(&a), Some(&b)) = (trimmed.get(j), trimmed.get((j + 1) % m)) else { continue };
let arc = from.get(j).copied().flatten().and_then(|i| Some((i, round.get(i).copied().flatten()?)));
let Some((i, (c, mid))) = arc else {
segs.push(Seg2::Line { a: snap(a), b: snap(b) });
continue;
};
let (Some(&p), Some(&q)) = (pts.get(i), pts.get((i + 1) % n)) else { continue };
if a.dist(p) > 1e-6 || b.dist(q) > 1e-6 {
return err("not supported yet: a round edge of a face that ends at a bend or a cut edge of the sheet");
}
let (start, r) = ((p - c).angle(), c.dist(p));
let half = ((mid - c).angle() - start + PI).rem_euclid(TAU) - PI;
match segs.last_mut() {
Some(Seg2::Arc { center, radius, sweep, .. })
if center.dist(c) <= 1e-9 && (*radius - r).abs() <= 1e-9 && *sweep * half > 0.0 && (*sweep + 2.0 * half).abs() <= PI + 1e-9 =>
{
*sweep += 2.0 * half;
}
_ => segs.push(Seg2::Arc { center: c, radius: r, start, sweep: 2.0 * half }),
}
}
Ok(Some(Loop2 { segs }))
}
/// Names of the edges of `body` at the bend points, to find them again after the body changes.
/// A point on no edge gets none: the conversion then says so, instead of bending the edge that
/// happens to be nearest.
/// happens to be nearest. A point on a chamfer or a bend cylinder gets none either: it stays that
/// chamfer's or cylinder's point.
pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<String> {
let Some(b) = st.body(body) else { return Vec::new() };
let (mesh, near) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3));
let on = |p: &Vec3| mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near));
let (mesh, near, tol) = (b.mesh(), (b.body.size() * 2e-3).max(1e-3), (b.body.size() * 1e-6).max(1e-6));
let on = |p: &Vec3| {
chamfer_at(b, &mesh, *p, tol, near).is_none() && mesh.edges.iter().any(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) <= near))
};
pts.iter().zip(crate::naming::edge_names_at(b, pts)).map(|(p, name)| if on(p) { name } else { String::new() }).collect()
}
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
/// fixed face and `bends` are points on the sharp edges that become bends (the body's
/// coordinates, mm). See the module docs.
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of
/// chamfers or on bend cylinders that do (the body's coordinates, mm). See the module docs.
/// Returns the sheet and a note for every corner or end of a bend not finished as the rule says.
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<(SheetBody, Vec<String>)> {
if bends.is_empty() || bends.len() > MAX_BENDS {
return err(format!("pick 1…{MAX_BENDS} bend edges"));
}
@@ -240,10 +690,20 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
let Some((_, f0)) = crate::appearance::nearest_face(mb, face).filter(|(d, _)| *d <= near) else {
return err(format!("no face of the body at the fixed face point {}", at(face)));
};
let fixed = outline(b, &mesh, f0, tol).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
// The two faces along each bend edge.
// The two faces along each bend edge, or on either side of each chamfer or bend cylinder
// (with a bend cylinder its other skin's too).
let mut picks = Vec::with_capacity(bends.len());
let mut chamfers: Vec<Chamfer> = Vec::new();
for p in bends {
if let Some(c) = chamfer_at(mb, &mesh, *p, tol, near) {
picks.push((*p, (c.a, c.b)));
for c in std::iter::once(c).chain(other_skin(b, &mesh, &c, tol)) {
if !chamfers.iter().any(|x| x.strip == c.strip) {
chamfers.push(c);
}
}
continue;
}
let dist = |e: &Vec<Vec3>| e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let edge = mesh.edges.iter().zip(&mesh.edge_faces).map(|(e, faces)| (dist(e), faces)).min_by(|x, y| x.0.total_cmp(&y.0));
match edge.filter(|(d, _)| *d <= near).map(|(_, faces)| faces.as_slice()) {
@@ -252,15 +712,31 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
None => return err(format!("no edge of the body at the bend point {}", at(*p))),
}
}
if let Some((p, _)) = picks.iter().find(|(_, (x, y))| chamfers.iter().any(|c| c.strip == *x || c.strip == *y)) {
return err(format!("the bend at {} is on a chamfer picked as a bend (pick the chamfer or its edges, not both)", at(*p)));
}
let face_of = |f: usize| outline(b, &mesh, f, tol).and_then(|face| without_chamfers(b, face, f, &chamfers, tol));
let fixed = face_of(f0).map_err(|e| DocError::Invalid(format!("the fixed face can't be a face of the sheet: {e}")))?;
// A shell: the same small distance of material behind the fixed face and the faces at every
// bend. It keeps its thickness, and a bend picked on one skin is that bend on the other too.
let thin = behind(b, &mesh, f0).map(|(_, d)| d).filter(|d| *d > 0.0 && d * d * 16.0 <= area_about(&fixed.pts, fixed.n));
let wall = |f: usize, d: f64| behind(b, &mesh, f).filter(|(_, e)| (e - d).abs() <= 1e-6 * d.max(1.0)).map(|(g, _)| g);
let backs: Vec<Option<(usize, usize)>> = picks.iter().map(|(_, (x, y))| thin.and_then(|d| Some((wall(*x, d)?, wall(*y, d)?)))).collect();
let shell = thin.filter(|_| backs.iter().all(Option::is_some));
// On a shell, the face behind each face at a bend (its other skin, read for its pockets).
let skin_behind: BTreeMap<usize, usize> = picks
.iter()
.zip(&backs)
.filter(|_| shell.is_some())
.filter_map(|((_, (x, y)), back)| back.map(|(bx, by)| [(*x, bx), (bx, *x), (*y, by), (by, *y)]))
.flatten()
.collect();
let t = shell.map_or(rv.thickness, |d| (d * 1e9).round() / 1e9);
let r = rv.bend_radius;
let joined = |x: usize, y: usize| mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)));
let joined = |x: usize, y: usize| {
mesh.edge_faces.iter().any(|faces| faces.contains(&(x as u32)) && faces.contains(&(y as u32)))
|| chamfers.iter().any(|c| (c.a, c.b) == (x, y) || (c.a, c.b) == (y, x))
};
let mut todo: Vec<(Vec3, Vec<(usize, usize)>)> = Vec::with_capacity(picks.len());
for (&(p, pair), back) in picks.iter().zip(&backs) {
let other = back.filter(|(x, y)| shell.is_some() && joined(*x, *y));
@@ -288,8 +764,8 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
}
return err(format!("the bend at {} closes a loop of bends (leave one of its edges unpicked: it is cut open)", at(p)));
}
let child = outline(b, &mesh, to, tol)
.map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?;
let child =
face_of(to).map_err(|e| DocError::Invalid(format!("the face across the bend at {} can't be a face of the sheet: {e}", at(p))))?;
let Some(parent) = panels.get(&from) else { continue };
let theta = parent.face.n.dot(child.n).clamp(-1.0, 1.0).acos();
if !(theta > 1e-6 && theta < std::f64::consts::PI - 1e-6) {
@@ -313,12 +789,46 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
// The flat frame: on the fixed face, x along its longest edge, z into the material.
let Some(base) = panels.get(&f0) else { return err("fixed face") };
let corners = base.face.pts.iter().zip(base.face.pts.iter().cycle().skip(1));
let longest = corners.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1)));
let straight = corners.zip(&base.face.round).filter(|(_, r)| r.is_none()).map(|(c, _)| c);
let longest = straight.max_by(|x, y| x.0.dist(*x.1).total_cmp(&y.0.dist(*y.1)));
let frame = longest
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
// A wall on part of an edge of the fixed face bends only where it stands (a bend picked as a
// chamfer or a cylinder is taken as it is).
let walls: Vec<(usize, f64)> = panels.iter().filter(|(_, p)| p.parent == Some(f0)).map(|(g, p)| (*g, p.setback)).collect();
for (g, by) in walls {
if chamfers.iter().any(|c| (c.a, c.b) == (f0, g) || (c.a, c.b) == (g, f0)) {
continue;
}
let kids: Vec<usize> = panels.iter().filter(|(_, p)| p.parent == Some(g)).map(|(h, _)| *h).collect();
let Some(mut wall) = panels.remove(&g) else { continue };
if let Some(base) = panels.get_mut(&f0) {
end_at_wall(&mut base.face, &mut wall.face, (f0, g), by, &|h| h == f0 || kids.contains(&h), tol);
}
panels.insert(g, wall);
}
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
// How far behind a face of the sheet the body's face `g` starts and ends: a hole's wall
// reaches through the sheet, a pocket's ends on its floor, an island's starts at the face.
let mut points: BTreeMap<usize, Vec<Vec3>> = BTreeMap::new();
for (tri, g) in mesh.triangles.iter().zip(&mesh.tri_face) {
points.entry(*g as usize).or_default().extend(tri.iter().filter_map(|k| mesh.positions.get(*k as usize)));
}
let span = |g: usize, face: &Face| {
let o = face.pts.first().copied().unwrap_or_default();
let ds = points.get(&g).into_iter().flatten().map(|p| (o - *p).dot(face.n));
ds.fold((f64::INFINITY, 0.0), |(lo, hi): (f64, f64), d| (lo.min(d.max(0.0)), hi.max(d)))
};
// The span of a loop's walls: the least any starts at, and the least and most any ends at.
let walls_span = |walls: &[usize], face: &Face| {
walls
.iter()
.map(|g| span(*g, face))
.fold((f64::INFINITY, f64::INFINITY, 0.0), |(s, lo, hi): (f64, f64, f64), (a, b)| (s.min(a), lo.min(b), hi.max(b)))
};
let mut work = FlatWork::new();
// Lay the panels out, parents first: each is its face, seen through the inverse of its
// fold, with its edges moved in by a bend's setback or a rip's cut.
let mut queue: Vec<(usize, Option<usize>)> = vec![(f0, None)];
@@ -344,16 +854,105 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
});
}
let mut across = panel.face.across.clone();
let flat = |p: Vec3| {
let q = apply_point(&inv, p);
Vec2::new(q.x, q.y)
};
let mut round: Vec<Option<(Vec2, Vec2)>> = panel.face.round.iter().map(|r| r.map(|(c, m)| (flat(c), flat(m)))).collect();
if signed_area(&pts) < 0.0 {
pts.reverse();
turn(&mut by);
turn(&mut across);
turn(&mut round);
}
let first = panel.face.pts.first().copied().unwrap_or_default();
let (trimmed, from) = inset(&pts, &by).map_err(|e| DocError::Invalid(format!("{e} (the face at {})", at(first))))?;
let face_at = |e: DocError| DocError::Invalid(format!("{e} (the face at {})", at(first)));
let (trimmed, from) = inset(&pts, &by).map_err(face_at)?;
let exact = drawn(&trimmed, &from, &pts, &round).map_err(face_at)?;
match fl {
None => sh.base.outer = Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>()),
Some(i) => set_panel(&mut sh, i, &trimmed),
None => sh.base.outer = exact.unwrap_or_else(|| Loop2::polygon(&trimmed.iter().map(|q| snap(*q)).collect::<Vec<_>>())),
Some(i) => {
set_panel(&mut sh, i, &trimmed);
if let (Some(lp), Some(f)) = (exact, sh.flanges.get_mut(i)) {
f.shape = Some(trimmed.clone());
f.exact = Some(lp);
}
}
}
// The face's holes: cut-outs of the sheet where they lie, inside its panel. A hole's walls
// reach through the sheet; a pocket's (an engraved label) end inside it, on floors facing
// out as the face does: a pocket of the sheet from that skin, round its islands. On a
// shell the face behind, the other skin, is read for its pockets too.
let region = match fl {
None => sh.base.clone(),
Some(i) => sh.flanges.get(i).map(|f| sh.panel(f)).unwrap_or_default(),
};
let back = skin_behind.get(&pf).and_then(|g| outline(b, &mesh, *g, tol).ok());
let skins: Vec<(&Face, bool)> = std::iter::once((&panel.face, true)).chain(back.as_ref().map(|f| (f, false))).collect();
let bound = if skins.iter().all(|(f, _)| f.holes.is_empty()) { Vec::new() } else { region.outer.ccw().polyline(1e-3) };
for (face, front) in skins {
for (h, walls) in &face.holes {
let p = face.plane.to_world(h.centroid());
let (_, reach, deepest) = walls_span(walls, face);
let through = reach >= t - tol;
if through && !front {
continue;
}
let cut = sh.region_to_flat(fl, &face.plane, &Region2 { outer: h.clone(), holes: Vec::new() })?;
let ring = cut.outer.polyline(1e-3);
if !ring.iter().all(|q| point_in(&bound, *q)) || polys_cross(&bound, &ring) {
let kind = if through { "hole" } else { "pocket" };
return err(format!("not supported yet: the {kind} at {} reaches into a bend (cut it after converting)", at(p)));
}
if through {
sh.holes.push(cut);
continue;
}
let pocket =
|why: &str| err(format!("not supported yet: the pocket at {} in a face of the sheet {why} (cut it after converting)", at(p)));
let eps = t * 1e-3;
if reach > t / 2.0 + eps {
return pocket(&format!("ends {reach:.3} deep, more than half the sheet's {t:.3}"));
}
if deepest - reach > eps {
return pocket("has walls of more than one depth");
}
// Its floors: the faces at its depth beside its walls, facing out as the face does.
let mut floors: Vec<usize> = mesh
.edge_faces
.iter()
.filter(|fs| fs.iter().any(|g| walls.contains(&(*g as usize))))
.flatten()
.map(|g| *g as usize)
.filter(|g| {
!walls.contains(g)
&& matches!(face_surface(b, *g), Some(FaceSurface::Plane { normal, .. }) if normal.dot(face.n) > 1.0 - 1e-9)
})
.filter(|g| {
let (lo, hi) = span(*g, face);
(lo - reach).abs() <= eps && (hi - reach).abs() <= eps
})
.collect();
floors.sort_unstable();
floors.dedup();
if floors.is_empty() {
return pocket("has no flat floor");
}
// The islands left standing in it (a letter's counter): walls from a floor up to the face.
let mut islands = Vec::new();
for g in floors {
let floor = outline(b, &mesh, g, tol)?;
for (k, kw) in &floor.holes {
let (start, lo, hi) = walls_span(kw, face);
if start > eps || (lo - reach).abs() > eps || (hi - reach).abs() > eps {
return pocket("has a hole or a step in its floor");
}
islands.push(sh.region_to_flat(fl, &floor.plane, &Region2 { outer: k.clone(), holes: Vec::new() })?.outer.reversed());
}
}
let (z0, z1) = if front { (0.0, reach) } else { (t - reach, t) };
sh.record_pocket(Region2 { outer: cut.outer, holes: islands }, z0, z1, &mut work)?;
}
}
// Its children: flanges on the trimmed edges they bend at.
let m = trimmed.len();
@@ -379,13 +978,32 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
ext: (0.0, 0.0),
slope: (0.0, 0.0),
shape: None,
exact: None,
tag: String::new(),
tear: (false, false),
});
queue.push((g, Some(sh.flanges.len() - 1)));
}
}
Ok(sh)
// The bends on the fixed face finished as an edge flange feature finishes its own
// (`SheetBody::add_flanges_each`): the rule's bend relief where one ends beside more of the
// fixed face; where two meet at a corner of it, their walls closed with the rule's gap (which
// the cuts above already are) and the rule's corner relief.
let made: Vec<usize> = (0..sh.flanges.len()).collect();
let mut notes = Vec::new();
for &i in &made {
notes.extend(sh.relieve_bend_ends(i, (false, false)));
}
let corners = sh.corners(&made);
let mut closed = Vec::with_capacity(corners.len());
for k in &corners {
match sh.close_corner(k) {
Ok(()) => closed.push(*k),
Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))),
}
}
notes.extend(sh.relieve_corners(&corners, &closed));
Ok((sh, notes))
}
#[cfg(test)]
+2
View File
@@ -33,6 +33,7 @@ fn plate_doc() -> (Document, u64) {
sketch: s,
profiles: ProfileSel::All,
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "20".into(),
direction: Direction::Positive,
distance2: None,
@@ -125,6 +126,7 @@ fn box_fillet_cut_timeline() {
sketch: s2,
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
extent: Extent {
convention: crate::ExtrudeConvention::LegacyPerSide,
distance: "20".into(),
direction: Direction::Positive,
distance2: None,
+390
View File
@@ -0,0 +1,390 @@
//! The walks over a feature: its expressions (`params`), its references to the rest of the design
//! (`feature_refs`) and its geometry (`feature_geometry`). `#[derive(Walk)]`
//! (`solidcraft-derive`) generates them from `#[walk(...)]` roles on the fields of `FeatureKind`
//! and the types inside it, so what a field is, is written once, where it is declared. The
//! traits here carry the walks through the types the fields have: options, lists, arrays, planes
//! and construction definitions.
pub(crate) use solidcraft_geom::Vec3;
pub(crate) use crate::ProfileSel;
use crate::construct::{AxisDef, GeoRef, PointDef};
pub(crate) use crate::expr::Kind;
pub(crate) use crate::feature_geometry::Geo;
pub(crate) use crate::feature_refs::Ref;
pub(crate) use crate::params::ExprRole;
use crate::{PlaneRef, PointExpr};
/// An expression: its label, the expression, its kind and its role.
pub(crate) type ExprItem<'a> = (&'static str, &'a String, Kind, ExprRole);
pub(crate) type ExprItemMut<'a> = (&'static str, &'a mut String, Kind, ExprRole);
pub(crate) type RefItem<'a> = Ref<&'a u64, &'a String>;
pub(crate) type RefItemMut<'a> = Ref<&'a mut u64, &'a mut String>;
pub(crate) type GeoItem<'a> = Geo<&'a Vec3, &'a PlaneRef, &'a GeoRef, &'a PointExpr, &'a ProfileSel>;
pub(crate) type GeoItemMut<'a> = Geo<&'a mut Vec3, &'a mut PlaneRef, &'a mut GeoRef, &'a mut PointExpr, &'a mut ProfileSel>;
/// A value's expressions, references and geometry, each walked in order. A type with none of
/// them implements it empty.
pub(crate) trait Walk {
fn walk_exprs<'a>(&'a self, _: &mut dyn FnMut(ExprItem<'a>)) {}
fn walk_exprs_mut<'a>(&'a mut self, _: &mut dyn FnMut(ExprItemMut<'a>)) {}
fn walk_refs<'a>(&'a self, _: &mut dyn FnMut(RefItem<'a>)) {}
fn walk_refs_mut<'a>(&'a mut self, _: &mut dyn FnMut(RefItemMut<'a>)) {}
fn walk_geometry<'a>(&'a self, _: &mut dyn FnMut(GeoItem<'a>)) {}
fn walk_geometry_mut<'a>(&'a mut self, _: &mut dyn FnMut(GeoItemMut<'a>)) {}
}
impl Walk for String {}
impl Walk for std::sync::Arc<str> {}
impl Walk for bool {}
impl Walk for u64 {}
impl Walk for i8 {}
impl Walk for f64 {}
impl Walk for ProfileSel {}
impl Walk for solidcraft_sketch::Sketch {}
impl Walk for solidcraft_kernel::StepNode {}
impl Walk for crate::document::Operation {}
impl Walk for solidcraft_kernel::Continuity {}
impl Walk for crate::document::Direction {}
impl Walk for crate::document::FilletFit {}
impl Walk for crate::document::MeshData {}
impl Walk for crate::document::RevolveConvention {}
impl Walk for crate::document::CoilSection {}
impl Walk for crate::document::ThinSide {}
impl Walk for crate::document::ExtendMode {}
impl Walk for crate::document::RuledMode {}
impl Walk for crate::document::RuledCorners {}
impl Walk for crate::document::ExtrudeConvention {}
/// Containers walk what they hold, in order.
macro_rules! walk_each {
($([$($g:tt)*] $t:ty),*) => {$(
impl<$($g)*> Walk for $t {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
for t in self {
t.walk_exprs(f)
}
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
for t in self {
t.walk_exprs_mut(f)
}
}
fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) {
for t in self {
t.walk_refs(f)
}
}
fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) {
for t in self {
t.walk_refs_mut(f)
}
}
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
for t in self {
t.walk_geometry(f)
}
}
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
for t in self {
t.walk_geometry_mut(f)
}
}
}
)*};
}
walk_each!([T: Walk] Option<T>, [T: Walk] Vec<T>, [T: Walk, const N: usize] [T; N]);
/// The values of a field with a role (`T`, an optional one or a list), in order.
pub(crate) trait Each<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T));
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T));
}
impl<T> Each<T> for T {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
f(self)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
f(self)
}
}
impl<T> Each<T> for Option<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
if let Some(t) = self {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
if let Some(t) = self {
f(t)
}
}
}
impl<T> Each<T> for Vec<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
for t in self {
f(t)
}
}
}
impl<T, const N: usize> Each<T> for [T; N] {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
for t in self {
f(t)
}
}
}
impl<T, const N: usize> Each<T> for Option<[T; N]> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
if let Some(a) = self {
for t in a {
f(t)
}
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
if let Some(a) = self {
for t in a {
f(t)
}
}
}
}
impl<T> Each<T> for Option<Vec<T>> {
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
for t in self.iter().flatten() {
f(t)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
for t in self.iter_mut().flatten() {
f(t)
}
}
}
/// The expressions of an expression field, each with its index and their count (for its label).
pub(crate) trait ExprField {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String));
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String));
}
impl ExprField for String {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
f(0, 1, self)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
f(0, 1, self)
}
}
/// A position along an edge with its radius (a variable fillet's mid radii).
impl ExprField for (f64, String) {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
f(0, 1, &self.1)
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
f(0, 1, &mut self.1)
}
}
impl<T: ExprField> ExprField for Option<T> {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
if let Some(t) = self {
t.each(f)
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
if let Some(t) = self {
t.each_mut(f)
}
}
}
/// Lists and arrays number their elements.
macro_rules! expr_list {
($([$($g:tt)*] $t:ty),*) => {$(
impl<$($g)*> ExprField for $t {
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
let n = self.len();
for (i, t) in self.iter().enumerate() {
t.each(&mut |_, _, e| f(i, n, e));
}
}
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
let n = self.len();
for (i, t) in self.iter_mut().enumerate() {
t.each_mut(&mut |_, _, e| f(i, n, e));
}
}
}
)*};
}
expr_list!([T: ExprField] Vec<T>, [T: ExprField, const N: usize] [T; N]);
/// An expression's label: one for every element, one per element, or worked out from the
/// element's index and the count.
pub(crate) trait Label {
fn at(&self, i: usize, n: usize) -> &'static str;
}
impl Label for &'static str {
fn at(&self, _: usize, _: usize) -> &'static str {
self
}
}
impl<const N: usize> Label for [&'static str; N] {
fn at(&self, i: usize, _: usize) -> &'static str {
self.get(i).copied().unwrap_or("")
}
}
impl<F: Fn(usize, usize) -> &'static str> Label for F {
fn at(&self, i: usize, n: usize) -> &'static str {
self(i, n)
}
}
/// The offsets and angles of a plane, outermost first (nesting is capped like every walk over a
/// plane), the construction plane it is built on, and the plane itself.
impl Walk for PlaneRef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, distance } => {
f(("Offset", distance, Kind::Length, ExprRole::Input));
p = base;
}
PlaneRef::AtAngle { base, angle, .. } => {
f(("Angle", angle, Kind::Angle, ExprRole::Input));
p = base;
}
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
_ => return,
}
}
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, distance } => {
f(("Offset", distance, Kind::Length, ExprRole::Input));
p = base;
}
PlaneRef::AtAngle { base, angle, .. } => {
f(("Angle", angle, Kind::Angle, ExprRole::Input));
p = base;
}
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
_ => return,
}
}
}
fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
_ => return,
}
}
}
fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) {
let mut p = self;
for _ in 0..64 {
match p {
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
_ => return,
}
}
}
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
f(Geo::Plane(self))
}
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
f(Geo::Plane(self))
}
}
/// Construction axes and points: the expressions of the planes they are built from (and a point
/// along a path's distance), and their references to the model. They name no sketches, features
/// or bodies: copies remap their geometry references on their own (`Feature::remap_clone`).
fn plane_exprs<'a>(refs: Vec<&'a GeoRef>, f: &mut dyn FnMut(ExprItem<'a>)) {
for r in refs {
if let GeoRef::Plane { plane } = r {
plane.walk_exprs(f);
}
}
}
fn plane_exprs_mut<'a>(refs: Vec<&'a mut GeoRef>, f: &mut dyn FnMut(ExprItemMut<'a>)) {
for r in refs {
if let GeoRef::Plane { plane } = r {
plane.walk_exprs_mut(f);
}
}
}
macro_rules! construct_geometry {
() => {
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
for r in self.refs() {
f(Geo::Construct(r));
}
}
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
for r in self.refs_mut() {
f(Geo::Construct(r));
}
}
};
}
impl Walk for AxisDef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
plane_exprs(self.refs(), f)
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
plane_exprs_mut(self.refs_mut(), f)
}
construct_geometry!();
}
impl Walk for PointDef {
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
match self {
PointDef::AlongPath { t, .. } => f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
other => plane_exprs(other.refs(), f),
}
}
fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(ExprItemMut<'a>)) {
match self {
PointDef::AlongPath { t, .. } => f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
other => plane_exprs_mut(other.refs_mut(), f),
}
}
construct_geometry!();
}
@@ -250,3 +250,41 @@ fn press_pull_on_the_enclosure_lid_is_prompt() {
}
assert!(total(&s) > before, "{before} -> {}", total(&s));
}
/// A lip swept round a block's rim, flush with its sides and round its corners, joins it as one
/// solid of both volumes: the block (50 × 40 with R4.5 corners, 10 high) and the lip (1 × 1 along
/// 41 + 31 + 41 mm of straight rim and two quarter turns between radii 3.5 and 4.5).
#[test]
fn lip_swept_flush_round_a_corner_joins() {
let s = replay(include_str!("../../../examples/regressions/lip_swept_flush_round_corner.json"));
let bodies = &s.model.state().bodies;
assert_eq!(bodies.len(), 1);
let b = &bodies[0];
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
assert_eq!(b.body.shell_count(), 1);
let block = (50.0 * 40.0 - (4.0 - std::f64::consts::PI) * 4.5 * 4.5) * 10.0;
let lip = 41.0 + 31.0 + 41.0 + 2.0 * std::f64::consts::PI * (4.5 * 4.5 - 3.5 * 3.5) / 4.0;
let volume = b.body.tessellate(1e-5).unwrap().measure().volume;
assert!((volume - block - lip).abs() < 0.02, "{volume} expected {}", block + lip);
}
/// #1275: an Extrude Cut of a whole face of a rounded-rectangle block with a bottom chamfer. The
/// tool's corner cylinders lay on the block's; they are grown off them now, as straight edges
/// were, and the cut takes 5 mm off the top.
#[test]
fn cut_of_a_whole_face_beside_round_corners_after_a_chamfer() {
let s = replay(include_str!("../../../examples/regressions/cut_whole_face_after_bottom_chamfer.json"));
let bodies = &s.model.state().bodies;
assert_eq!(bodies.len(), 1);
let b = &bodies[0];
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
let pi = std::f64::consts::PI;
let block = (40.0 * 40.0 - (4.0 - pi) * 8.0 * 8.0) * 15.0;
// The chamfer's 2 × 2 triangle swept along the loop (Pappus: its centroid is 2/3 mm in).
let chamfer = 2.0 * (4.0 * 24.0 + 2.0 * pi * (8.0 - 2.0 / 3.0));
let mesh = b.body.tessellate(1e-4).unwrap();
let volume = mesh.measure().volume;
assert!((volume - (block - chamfer)).abs() < 0.05, "{volume} expected {}", block - chamfer);
let top = mesh.bounds().max.z;
assert!((top - 15.0).abs() < 1e-6, "top at {top}");
}
+56
View File
@@ -520,7 +520,33 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
let fid = b.feature;
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
// A body numbered by the counter (`BodyN`) gives its number back once it has a name of its
// own, and every counted body after it would move down one (`Body3` becoming `Body1`) and
// break what names it. Those later features keep the names they made too (#1109): each one's
// own result holds them, also for bodies a later feature joins away.
let counted = |f: &solidcraft_doc::Feature, i: usize| {
let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty());
f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first)
};
let later: Vec<(u64, Vec<String>)> = match s.doc.features.iter().position(|f| f.id == fid) {
Some(at) if s.doc.features.get(at).is_some_and(|f| counted(f, k)) => s
.doc
.features
.iter()
.skip(at + 1)
.filter_map(|f| {
let made: Vec<String> = s.model.result(f.id)?.state.bodies.iter().filter(|x| x.feature == f.id).map(|x| x.name.clone()).collect();
(0..made.len()).any(|i| counted(f, i)).then_some((f.id, made))
})
.collect(),
_ => Vec::new(),
};
let d = s.doc_mut();
for (id, made) in &later {
if let Some(f) = d.feature_mut(*id) {
f.body_names.clone_from(made);
}
}
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
while f.body_names.len() < siblings.len().max(k + 1) {
let i = f.body_names.len();
@@ -763,6 +789,36 @@ mod tests {
assert_eq!(s.doc.find_feature("Hole").unwrap().participants, vec!["A".to_string()]);
}
/// #1109: renaming counted bodies (`Body1`, `Body2` of one pattern) leaves the counted bodies
/// of later features as they were (`Body3`, `Body4`), and what names them keeps working.
#[test]
fn renaming_counted_bodies_keeps_later_counted_names() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [0, 0, 2.5], "name": "B1", "body_name": "First"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["B1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 10, "name": "P1"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [0, 20, 2.5], "name": "B2", "body_name": "Second"}));
run(&mut s, "solid.pattern.rectangular", json!({"features": ["B2"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 10, "name": "P2"}));
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "center": [10, 25, 2.5], "operation": "new", "name": "Bar"}));
let before = names(&s);
assert!(["Body1", "Body2", "Body3", "Body4", "Body5"].iter().all(|n| before.contains(&n.to_string())), "{before:?}");
run(&mut s, "solid.combine", json!({"target": "Body3", "tools": ["Body5"], "operation": "join", "name": "Joined"}));
// Renaming a body that already has a name of its own stores nothing elsewhere.
run(&mut s, "body.rename", json!({"body": "Second", "name": "Row"}));
assert!(s.doc.find_feature("P2").unwrap().body_names.is_empty());
run(&mut s, "body.rename", json!({"body": "Body1", "name": "FirstCopyA"}));
run(&mut s, "body.rename", json!({"body": "Body2", "name": "FirstCopyB"}));
assert!(
s.model.results.iter().all(|r| r.error.is_none()),
"{:?}",
s.model.results.iter().filter_map(|r| r.error.clone()).collect::<Vec<_>>()
);
let after = names(&s);
assert!(after.contains(&"Body3".to_string()) && after.contains(&"Body4".to_string()), "{after:?}");
assert!(!after.contains(&"Body1".to_string()) && !after.contains(&"Body5".to_string()), "{after:?}");
run(&mut s, "edit.undo", json!({}));
assert!(names(&s).contains(&"Body2".to_string()) && names(&s).contains(&"Body4".to_string()), "{:?}", names(&s));
}
#[test]
fn renaming_a_body_leaves_other_text_with_its_name_alone() {
let mut s = Session::default();
+10 -2
View File
@@ -374,9 +374,14 @@ mod tests {
let second = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
paste_import_with_two_lifts(&mut s, &second, ["Aa (2)", "Bb (2)", "Cc (2)"]);
// Each import is a component of its own (#1386), shown after the pasted copies: the first
// import's bodies stay where they were, the second's and their copies are lifted.
let plain = [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [60.0, 0.0, 0.0]];
let at: Vec<Vec<f64>> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect();
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&plain).chain(&LIFTED).map(|c| c.to_vec()).collect();
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
let m = run(&mut s, "inspect.measure", json!({}));
let names: Vec<&str> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["name"].as_str()).collect();
assert_eq!(names, ["Aa (copy)", "Bb (copy)", "Cc (copy)", "Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
}
/// A cylinder Emboss finds its face by the persistent name it recorded when it was made. The
@@ -522,6 +527,9 @@ mod tests {
assert_eq!(def["definition"]["targets"], json!(["Plate (copy)"]), "{def}");
let st = s.world_state();
let v = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume;
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-6, "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
// A spring on the wrong plate is hundreds of mm³ off. The two plates are measured apart
// (one moved 50 mm), which the boolean and the measure each round in their own way:
// a few 1e-6 mm³ on 5000.
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-8 * v("Plate"), "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
}
}
+309
View File
@@ -0,0 +1,309 @@
//! Compare Bodies: two versions of one part laid over each other. Both are measured, each
//! surface's distance to the other is sampled, and the places where only one of them has
//! material are found, measured and, on request, coloured. It works from the surfaces
//! ([`solidcraft_geom::compare`]), not from a boolean of the two bodies, so bodies that share
//! almost every face compare as well as any.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_geom::compare::{self, CompareOptions, Side};
use super::CommandSpec;
use crate::params::bad;
use crate::{Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params(
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
(colours the faces that hold a region, red where only a has material and green where only b has: the faces mostly beyond the tolerance, else the \
one with the largest share; an undo step) → a, b: volume_mm3, area_mm2, center_of_mass, bbox; difference: b − a; \
deviation: a_to_b, b_to_a (max_mm, mean_mm, at, area_beyond_mm2); regions, largest first: side (only_in_a | only_in_b), volume_mm3 (integrated along \
lines through the region: resolution), area_mm2, max_deviation_mm, at, center, bbox, faces_a, faces_b (face indices, as model.faces lists them); \
identical; verdict; warnings (a body whose surface does not enclose what it measures, lines left out). Works from the two surfaces, without a boolean of the bodies, so near-identical bodies compare too",
)];
/// Default colours: red for material only `a` has, green for material only `b` has.
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
fn side_name(s: Side) -> &'static str {
match s {
Side::OnlyInA => "only_in_a",
Side::OnlyInB => "only_in_b",
}
}
/// A number parameter in `range`, `default` when absent.
fn number(cmd: &str, p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_f64()
.filter(|x| x.is_finite() && range.contains(x))
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from {} to {}", range.start(), range.end()))),
}
}
/// The two bodies: `a` and `b`, else the two selected bodies, else the design's only two.
fn pair(s: &Session, st: &solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<(String, String)> {
let given = |k: &str| -> Result<Option<String>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(n)) => Ok(Some(n.clone())),
Some(_) => Err(bad(cmd, format!("`{k}` must be a body name"))),
}
};
let (a, b) = match (given("a")?, given("b")?) {
(Some(a), Some(b)) => (a, b),
(None, None) => {
let selected: Vec<String> =
s.selection.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
let all: Vec<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
match (selected.as_slice(), all.as_slice()) {
([a, b], _) | (_, [a, b]) => (a.clone(), b.clone()),
_ => return Err(bad(cmd, "give `a` and `b` (or select two bodies)")),
}
}
_ => return Err(bad(cmd, "give both `a` and `b`")),
};
for n in [&a, &b] {
if st.body(n).is_none() {
return Err(bad(cmd, format!("no body `{n}`")));
}
}
if a == b {
return Err(bad(cmd, "`a` and `b` must be two different bodies"));
}
Ok((a, b))
}
fn colours(cmd: &str, p: &Value) -> Result<Option<[[u8; 3]; 2]>> {
match p.get("color") {
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
Some(Value::Bool(true)) => Ok(Some([ONLY_IN_A, ONLY_IN_B])),
Some(Value::Object(o)) => {
let pick = |k: &str, d: [u8; 3]| match o.get(k) {
None | Some(Value::Null) => Ok(d),
Some(v) => {
super::inspect::color_param(v).ok_or_else(|| bad(cmd, format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255")))
}
};
Ok(Some([pick("only_in_a", ONLY_IN_A)?, pick("only_in_b", ONLY_IN_B)?]))
}
Some(_) => Err(bad(cmd, "`color` must be true or {only_in_a, only_in_b}")),
}
}
fn v3(v: &Value) -> Option<Vec3> {
crate::params::vec3(v)
}
/// b − a of the two bodies' measures.
fn difference(ma: &Value, mb: &Value) -> Value {
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
json!({
"volume_mm3": sub(va, vb),
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
"center_of_mass": com,
"center_of_mass_distance_mm": com.map(Vec3::len),
"bbox_min": corner("min"),
"bbox_max": corner("max"),
})
}
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
fn enclosed_volume(m: &solidcraft_geom::Mesh) -> f64 {
let o = m.bounds().center();
m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| (a - o).dot((b - o).cross(c - o)) / 6.0).sum()
}
/// What makes the comparison less certain: a body whose surface does not enclose what it
/// measures (open, or faces that failed), lines left out of region volumes.
fn warnings(bodies: [(&str, &Value, &solidcraft_geom::Mesh); 2], chord: f64, c: &compare::Comparison) -> Vec<String> {
let mut out = Vec::new();
for (name, measure, mesh) in bodies {
let (Some(v), area) = (measure["volume_mm3"].as_f64(), measure["area_mm2"].as_f64().unwrap_or(0.0)) else { continue };
let enclosed = enclosed_volume(mesh);
// A mesh within `chord` of the surface encloses the volume to within chord × area.
if !((enclosed - v).abs() <= chord * area.abs() + 1e-6 * v.abs()) {
out.push(format!(
"`{name}`: its surface encloses {enclosed:.3} mm³ but the body measures {v:.3} mm³: it is open or has faces that failed, so its measures and the regions near the gap are unreliable"
));
}
}
let skipped: usize = c.regions.iter().map(|r| r.lines.skipped).sum();
if skipped > 0 {
out.push(format!("{skipped} line(s) through the regions crossed a gap in a surface and were left out of their volumes"));
}
out
}
fn deviation_json(d: &compare::Deviation) -> Value {
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
}
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.compare";
let tolerance = number(cmd, p, "tolerance", 0.01, 0.001..=10.0)?;
let spacing = number(cmd, p, "spacing", 0.5, 0.01..=1000.0)?;
let max_points = number(cmd, p, "max_points", 8e6, 1e4..=5e7)? as usize;
let max_regions = number(cmd, p, "max_regions", 50.0, 1.0..=1000.0)? as usize;
let colors = colours(cmd, p)?;
let st = s.world_state();
let (na, nb) = pair(s, &st, p, cmd)?;
let (Some(ba), Some(bb)) = (st.body(&na), st.body(&nb)) else { return Err(bad(cmd, "no such body")) };
// Meshed within a quarter of the tolerance (the kernel goes no finer than a millionth of a
// body's size), so two meshes of one surface agree well within it.
let chord = tolerance / 4.0;
let mut done = solidcraft_kernel::map_on_threads(&[ba, bb], 2, |b| (super::inspect::measure_json(b), b.body.tessellate_with(chord, true)))
.into_iter()
.zip([&na, &nb]);
let mut next = || -> Result<(Value, solidcraft_geom::Mesh)> {
let Some((r, name)) = done.next() else { return Err(bad(cmd, "no such body")) };
let (m, mesh) = r.ok_or_else(|| bad(cmd, format!("`{name}`: measuring it failed")))?;
let mesh = mesh.map_err(|e| bad(cmd, format!("`{name}`: {e}")))?;
if mesh.triangles.is_empty() {
return Err(bad(cmd, format!("`{name}` has no surface")));
}
Ok((m, mesh))
};
let ((measure_a, mesh_a), (measure_b, mesh_b)) = (next()?, next()?);
let options = CompareOptions { tolerance, spacing, max_points, max_regions, ..CompareOptions::default() };
let each = |n: usize, job: &(dyn Fn(usize) + Sync)| {
let jobs: Vec<usize> = (0..n).collect();
solidcraft_kernel::for_each_on_threads(&jobs, 16, |i| job(*i));
};
let c = compare::compare(&mesh_a, &mesh_b, &options, &each);
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
let regions: Vec<Value> = c
.regions
.iter()
.enumerate()
.map(|(i, r)| {
json!({
"index": i + 1,
"side": side_name(r.side),
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
"volume_mm3": r.volume,
"area_mm2": r.area_a + r.area_b,
"area_a_mm2": r.area_a,
"area_b_mm2": r.area_b,
"max_deviation_mm": r.max_deviation,
"at": r.at,
"center": r.center,
"bbox": {"min": r.bbox.min, "max": r.bbox.max},
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
})
})
.collect();
let total = |side: Side| {
let rs: Vec<&compare::Region> = c.regions.iter().filter(|r| r.side == side).collect();
// `+ 0.0`: an empty sum is -0.0.
(rs.len(), rs.iter().map(|r| r.volume).filter(|v| v.is_finite()).sum::<f64>() + 0.0)
};
let ((ca, va), (cb, vb)) = (total(Side::OnlyInA), total(Side::OnlyInB));
let worst = c.a_to_b.max.max(c.b_to_a.max);
let identical = c.regions.is_empty() && c.unlisted == 0;
let warnings = warnings([(na.as_str(), &measure_a, &mesh_a), (nb.as_str(), &measure_b, &mesh_b)], chord, &c);
let mut verdict = if identical {
format!("identical within {tolerance} mm (largest deviation {worst:.4} mm)")
} else {
let more = if c.unlisted > 0 { format!(" (and {} more, not measured)", c.unlisted) } else { String::new() };
format!(
"{} {} by more than {tolerance} mm: {ca} with material only in `{na}` ({va:.3} mm³), {cb} only in `{nb}` ({vb:.3} mm³){more}",
c.regions.len(),
if c.regions.len() == 1 { "region differs" } else { "regions differ" }
)
};
if !warnings.is_empty() {
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
}
let mut out = json!({
"a": measure_a,
"b": measure_b,
"difference": difference(&measure_a, &measure_b),
"tolerance_mm": tolerance,
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
"identical": identical,
"verdict": verdict,
"regions": regions,
"only_in_a": {"regions": ca, "volume_mm3": va},
"only_in_b": {"regions": cb, "volume_mm3": vb},
"unlisted_regions": c.unlisted,
"warnings": warnings,
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
});
if let Some(colors) = colors {
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
}
Ok(out)
}
/// A face is coloured for a region when at least this share of it deviates there.
const MOSTLY: f64 = 0.5;
/// The faces to colour for a region, as (body: 0 = a, 1 = b; face): those mostly beyond the
/// tolerance (a small difference on a large face would colour all of it), or, when it has none,
/// the one with the largest share.
fn faces_to_color(c: &compare::Comparison, r: &compare::Region) -> Vec<(usize, u32)> {
let share = |m: usize, &(f, beyond): &(u32, f64)| {
let all = c.face_area.get(m).and_then(|x| x.get(&f)).copied().unwrap_or(0.0);
(m, f, if all > 0.0 { beyond / all } else { 0.0 })
};
let all: Vec<(usize, u32, f64)> = r.faces_a.iter().map(|x| share(0, x)).chain(r.faces_b.iter().map(|x| share(1, x))).collect();
let mostly: Vec<(usize, u32)> = all.iter().filter(|x| x.2 >= MOSTLY).map(|x| (x.0, x.1)).collect();
if !mostly.is_empty() {
return mostly;
}
all.iter().max_by(|x, y| x.2.total_cmp(&y.2)).map(|x| vec![(x.0, x.1)]).unwrap_or_default()
}
/// Give the faces holding each region the colour of its side (a face holding several takes the
/// largest one's); returns how many face appearances were set.
fn color_regions(s: &mut Session, c: &compare::Comparison, names: [&String; 2], colors: [[u8; 3]; 2]) -> Result<usize> {
use solidcraft_doc::appearance::{FaceLook, Look};
let st = s.model.state();
// Where each face of the two bodies is, as the design file names and finds it. A body placed
// by an occurrence is coloured where it is defined, if at all.
let found: Vec<_> =
names.iter().map(|n| st.body(n).map(|b| (solidcraft_doc::naming::face_spots(b), solidcraft_doc::naming::face_names(b)))).collect();
let mut picks: Vec<(String, Vec3, Option<String>, Look)> = Vec::new();
// Smallest region first, so the largest one's colour is the one that stays.
for r in c.regions.iter().rev() {
let colour = if r.side == Side::OnlyInA { colors[0] } else { colors[1] };
let mut look = Look::custom(colour, 1.0);
look.name = if r.side == Side::OnlyInA { "Only in a".into() } else { "Only in b".into() };
for (m, f) in faces_to_color(c, r) {
let (Some(Some((spots, face_names))), Some(name)) = (found.get(m), names.get(m)) else { continue };
let Some(spot) = spots.get(f as usize).copied().flatten() else { continue };
picks.retain(|(b, pt, _, _)| !(b == *name && pt.dist(spot.point) < 1e-9));
picks.push(((*name).clone(), spot.point, face_names.get(f as usize).cloned(), look.clone()));
}
}
if picks.is_empty() {
return Ok(0);
}
let a = &mut s.doc_mut().appearances;
let mut n = 0;
for (body, point, name, look) in picks {
a.faces.retain(|f| !(f.body == body && f.point.dist(point) < 1e-9));
if a.faces.len() >= 100_000 {
return Err(bad("inspect.compare", "too many face appearances"));
}
a.faces.push(FaceLook { body, point, look, name, strict_name: false, certificate: None });
n += 1;
}
Ok(n)
}
#[cfg(test)]
#[path = "compare_tests.rs"]
mod tests;
+212
View File
@@ -0,0 +1,212 @@
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn regions(r: &Value) -> Vec<Value> {
r["regions"].as_array().cloned().unwrap_or_default()
}
fn f(v: &Value) -> f64 {
v.as_f64().unwrap_or(f64::NAN)
}
fn close(got: f64, want: f64, rel: f64) -> bool {
(got - want).abs() <= rel * want.abs()
}
/// A 40 x 30 x 10 box `A` and its copy `A (2)`, in the same place.
fn two_boxes() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
run(&mut s, "solid.move", json!({"bodies": ["A"], "translate": [0, 0, 0], "copy": true}));
s
}
#[test]
fn a_box_and_its_copy_are_identical() {
let mut s = two_boxes();
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
assert_eq!(r["identical"], true, "{r}");
assert_eq!(r["warnings"], json!([]), "{r}");
assert!(regions(&r).is_empty());
assert!(f(&r["deviation"]["max_mm"]) < 1e-9, "{}", r["deviation"]);
assert_eq!(f(&r["difference"]["volume_mm3"]), 0.0);
assert!(close(f(&r["a"]["volume_mm3"]), 12_000.0, 1e-9));
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("identical within 0.01 mm"), "{}", r["verdict"]);
// Without names: the design's only two bodies. Comparing changes nothing.
let before = s.doc.clone();
let r = run(&mut s, "inspect.compare", json!({}));
assert_eq!(r["identical"], true);
assert!(std::sync::Arc::ptr_eq(&before, &s.doc));
}
#[test]
fn a_pocket_is_material_only_the_plain_box_has() {
// b has a 10 x 8 x 0.5 pocket in its top: 40 mm³ that only a has.
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 10, "width": 8, "height": 0.5, "corner": [10, 10, 9.5], "operation": "cut", "targets": ["A (2)"]}));
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
let rs = regions(&r);
assert_eq!(rs.len(), 1, "{r}");
let g = &rs[0];
assert_eq!(g["side"], "only_in_a");
assert_eq!(g["material_of"], "A");
assert!(close(f(&g["volume_mm3"]), 40.0, 0.01), "{g}");
assert!((f(&g["max_deviation_mm"]) - 0.5).abs() < 1e-6, "{g}");
let (lo, hi) = (&g["bbox"]["min"], &g["bbox"]["max"]);
for (k, (want_lo, want_hi)) in [(10.0, 20.0), (10.0, 18.0), (9.5, 10.0)].into_iter().enumerate() {
assert!((f(&lo[k]) - want_lo).abs() < 0.05 && (f(&hi[k]) - want_hi).abs() < 0.05, "{g}");
}
let c = &g["center"];
assert!((f(&c[0]) - 15.0).abs() < 0.02 && (f(&c[1]) - 14.0).abs() < 0.02 && (f(&c[2]) - 9.75).abs() < 1e-6, "{g}");
// The pocket's floor and walls, and a's top.
assert_eq!(g["faces_b"].as_array().map(Vec::len), Some(5), "{g}");
assert_eq!(g["faces_a"].as_array().map(Vec::len), Some(1), "{g}");
assert!(close(f(&r["difference"]["volume_mm3"]), -40.0, 1e-6), "{}", r["difference"]);
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 40.0, 0.01));
assert_eq!(r["only_in_b"]["regions"], 0);
// The other way round, the same material is only in b.
let r = run(&mut s, "inspect.compare", json!({"a": "A (2)", "b": "A"}));
assert_eq!(regions(&r).len(), 1);
assert_eq!(regions(&r)[0]["side"], "only_in_b");
assert_eq!(regions(&r)[0]["material_of"], "A");
}
#[test]
fn a_boss_is_material_only_the_box_with_it_has() {
// b has a 6 x 5 x 2 boss on its top: 60 mm³ that only b has.
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 6, "width": 5, "height": 2, "corner": [5, 5, 10], "operation": "join", "targets": ["A (2)"]}));
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": true}));
let rs = regions(&r);
assert_eq!(rs.len(), 1, "{r}");
assert_eq!(rs[0]["side"], "only_in_b");
assert!(close(f(&rs[0]["volume_mm3"]), 60.0, 0.01), "{}", rs[0]);
assert!((f(&rs[0]["max_deviation_mm"]) - 2.0).abs() < 1e-6);
assert!(close(f(&r["difference"]["volume_mm3"]), 60.0, 1e-6));
// Coloured green: the boss's top and four sides, all of them beyond the tolerance; not a's top,
// of which only the boss's footprint is. One undo step.
assert_eq!(r["colored_faces"], 5, "{r}");
let green = |s: &Session, body: &str| s.doc.appearances.faces.iter().filter(|x| x.body == body && x.look.color == [0x2f, 0x9e, 0x44]).count();
assert_eq!((green(&s, "A (2)"), green(&s, "A")), (5, 0));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.appearances.faces.len(), 0);
// Own colours.
let r = run(&mut s, "inspect.compare", json!({"color": {"only_in_b": "#0000ff"}}));
assert_eq!(r["colored_faces"], 5);
assert_eq!(s.doc.appearances.faces.iter().filter(|x| x.look.color == [0, 0, 255]).count(), 5);
}
/// A 100 x 60 sheet-metal plate with a 20 mm flange along y = 0 and a 5 mm hole at (x, 40).
fn bracket(s: &mut Session, name: &str, x: f64) {
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} base")}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"sketch": format!("{name} base"), "body_name": name}));
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20, "body": name}));
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} hole")}));
run(s, "sketch.circle.center", json!({"center": [x, 40], "diameter": 5}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"sketch": format!("{name} hole"), "through_all": true, "operation": "cut", "targets": [name]}));
}
#[test]
fn a_hole_moved_2_mm_leaves_two_crescents() {
let mut s = Session::default();
bracket(&mut s, "Before", 50.0);
bracket(&mut s, "After", 52.0);
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After"}));
let rs = regions(&r);
assert_eq!(rs.len(), 2, "{r}");
// Each is a disk of 2.5 mm radius less its overlap with the other, centres 2 mm apart, through
// the 2.5 mm sheet: (π r² − 2 r² acos(d / 2r) + (d / 2) √(4 r² − d²)) · t.
let (rr, d, t) = (2.5f64, 2.0f64, 2.5);
let crescent = (std::f64::consts::PI * rr * rr - 2.0 * rr * rr * (d / (2.0 * rr)).acos() + d / 2.0 * (4.0 * rr * rr - d * d).sqrt()) * t;
assert!((crescent - 24.316_34).abs() < 1e-4);
for g in &rs {
assert!(close(f(&g["volume_mm3"]), crescent, 0.02), "{g}");
let c = &g["center"];
assert!((f(&c[1]) - 40.0).abs() < 0.05 && (f(&c[2]) - 1.25).abs() < 0.01, "{g}");
// Material only Before has is where After's hole is (beyond x = 51), and the reverse.
match g["side"].as_str() {
Some("only_in_a") => assert!(f(&c[0]) > 51.5, "{g}"),
Some("only_in_b") => assert!(f(&c[0]) < 50.5, "{g}"),
_ => panic!("{g}"),
}
}
assert_ne!(rs[0]["side"], rs[1]["side"]);
assert!(f(&r["difference"]["volume_mm3"]).abs() < 1e-3, "{}", r["difference"]);
// Coloured: the hole walls, not the plate's faces, of which only the crescents deviate.
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After", "color": true}));
let n = r["colored_faces"].as_u64().unwrap_or(0) as usize;
assert!((2..=8).contains(&n), "{r}");
let looks = &s.doc.appearances.faces;
assert_eq!(looks.len(), n);
for l in looks {
assert!(((l.point.x - 51.0).powi(2) + (l.point.y - 40.0).powi(2)).sqrt() < 4.0, "{:?}", l.point);
}
}
#[test]
fn a_part_and_its_step_round_trips_are_identical() {
// Curved faces too: a hole and a rounded edge.
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"diameter": 8, "height": 10, "base": [20, 15, 0], "operation": "cut", "targets": ["Part"]}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 3}));
let dir = std::env::temp_dir().join(format!("solidcraft-compare-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("part.step");
run(&mut s, "file.export", json!({"path": path, "bodies": ["Part"]}));
let mut inserted = Vec::new();
for _ in 0..2 {
let r = run(&mut s, "file.insert_step", json!({"path": path}));
inserted.push(r["created_bodies"][0].as_str().unwrap().to_string());
}
let _ = std::fs::remove_dir_all(&dir);
for (a, b) in [("Part", inserted[0].as_str()), (inserted[0].as_str(), inserted[1].as_str())] {
let r = run(&mut s, "inspect.compare", json!({"a": a, "b": b}));
assert_eq!(r["identical"], true, "{a} vs {b}: {r}");
assert!(f(&r["deviation"]["max_mm"]) < 0.005, "{a} vs {b}: {}", r["deviation"]);
assert!(f(&r["difference"]["volume_mm3"]).abs() < 0.05, "{a} vs {b}: {}", r["difference"]);
}
}
#[test]
fn bad_parameters_are_refused() {
let mut s = two_boxes();
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "C"}));
for p in [
json!({}),
json!({"a": "A"}),
json!({"a": "A", "b": "A"}),
json!({"a": "A", "b": "nope"}),
json!({"a": "A", "b": 3}),
json!({"a": "A", "b": "C", "tolerance": 0}),
json!({"a": "A", "b": "C", "tolerance": -1}),
json!({"a": "A", "b": "C", "tolerance": "a lot"}),
json!({"a": "A", "b": "C", "tolerance": 1e300}),
json!({"a": "A", "b": "C", "spacing": 0}),
json!({"a": "A", "b": "C", "max_regions": 0}),
json!({"a": "A", "b": "C", "max_points": 10}),
json!({"a": "A", "b": "C", "color": "blue"}),
json!({"a": "A", "b": "C", "color": {"only_in_a": "#12345"}}),
] {
assert!(s.execute("inspect.compare", &p).is_err(), "{p}");
}
// Two selected bodies are the pair.
s.selection = vec![crate::Sel::Body { name: "A".into() }, crate::Sel::Body { name: "C".into() }];
let r = run(&mut s, "inspect.compare", json!({}));
assert_eq!(r["a"]["name"], "A");
assert_eq!(r["b"]["name"], "C");
assert_eq!(r["only_in_a"]["regions"], 1, "{r}");
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 12_000.0 - 125.0, 0.01), "{r}");
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("1 region differs by more than 0.01 mm"), "{}", r["verdict"]);
}
+56 -6
View File
@@ -30,7 +30,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("component")
.params("bodies: [names] → one new component per body, named after it"),
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design); occurrence?, path?: exact placement used to author shared geometry"),
CommandSpec::new("component.list", "List Components", list).noundo(),
CommandSpec::new("component.list", "List Components", list).noundo().params(
"compact?: bool (counts of bodies, features, sketches and construction planes instead of their names; occurrences leave out \
identity transforms; children as occurrence ids); bodies?: bool (with compact: body names, not their count)",
),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
.icon("measure").noundo()
.params("component?: id or name (default active); descendants?: bool (default true). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates"),
@@ -39,6 +42,10 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true). Physical properties of this placement in world coordinates, including pending moves"),
CommandSpec::new("component.rename", "Rename Component", rename).params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
"component: removes the rigid joints that hold its contents together (an imported assembly's rigid group), so its parts \
move and take joints one by one → joints (the ids removed)",
),
CommandSpec::new("component.move_bodies", "Move Bodies to Component", move_bodies).params("bodies: [names], component"),
CommandSpec::new("component.move_sketches", "Move Sketches to Component", move_sketches).params("sketches: [ids or names], component"),
CommandSpec::new("occurrence.copy", "Paste (Instance)", copy_occurrence)
@@ -156,9 +163,23 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "active": id }))
}
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.model.state();
let doc = &s.doc;
// Compact: what a large assembly needs to be found around in, without every name and matrix.
let compact = bool_(p, "compact").unwrap_or(false);
let names = |v: Vec<&str>| if compact { json!(v.len()) } else { json!(v) };
let body_names = !compact || bool_(p, "bodies").unwrap_or(false);
let placement = |o: &Occurrence| {
if !compact {
return json!(o);
}
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
v["transform"] = json!(o.transform);
}
v
};
let node = |id: u64, name: &str, parent: Option<u64>| {
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.model_body_component(b) == id).map(|b| b.name.as_str()).collect();
let features: Vec<&str> = doc
@@ -175,10 +196,11 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
.filter(|f| f.component == id && matches!(f.kind, FeatureKind::ConstructionPlane { .. }))
.map(|f| f.name.as_str())
.collect();
let occurrences: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.component == id).collect();
let children: Vec<&Occurrence> = doc.occurrences.iter().filter(|o| o.parent == id).collect();
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": features, "sketches": sketches,
"construction": construction, "occurrences": occurrences, "children": children})
let occurrences: Vec<Value> = doc.occurrences.iter().filter(|o| o.component == id).map(placement).collect();
let children: Vec<Value> = doc.occurrences.iter().filter(|o| o.parent == id).map(|o| if compact { json!(o.id) } else { json!(o) }).collect();
let bodies = if body_names { json!(bodies) } else { json!(bodies.len()) };
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": names(features), "sketches": names(sketches),
"construction": names(construction), "occurrences": occurrences, "children": children})
};
let mut out = vec![node(0, &doc.name, None)];
for c in &doc.components {
@@ -233,6 +255,23 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"deleted": gone, "features": feats}))
}
fn unlock(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.unlock";
let id = component(s, p, "component", cmd)?;
if id == 0 {
return Err(bad(cmd, "pick a component, not the design"));
}
let gone = s.contents_joints(id);
if gone.is_empty() {
return Err(bad(cmd, "nothing holds this component's contents together"));
}
let doc = s.doc_mut();
doc.assembly.joints.retain(|j| !gone.contains(&j.id));
doc.assembly.links.retain(|l| !gone.contains(&l.a) && !gone.contains(&l.b));
s.visibility.hidden_joints.retain(|h| !gone.contains(h));
Ok(json!({"component": id, "joints": gone}))
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
transfer::move_bodies(s, p)
}
@@ -358,6 +397,17 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
if o.grounded {
return Err(bad(cmd, format!("{} is grounded", o.name)));
}
if let Some(mut group) = s.held_by_rigid_group(id) {
// The outermost component the group holds it in moves it.
for _ in 0..64 {
match s.doc.occurrence_of(group).and_then(|o| s.held_by_rigid_group(o.id)) {
Some(up) => group = up,
None => break,
}
}
let name = s.doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default();
return Err(bad(cmd, format!("{} is held in {name} by a rigid group: move {name}, or Unlock Contents (component.unlock) first", o.name)));
}
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
let angle = match p.get("angle") {
+1 -1
View File
@@ -346,7 +346,7 @@ pub(super) fn paste(
bodies.insert(body.name.clone(), new_name.clone());
let feature_name = unique(&format!("{} Base", body.name), &mut used);
let id = scratch.doc_mut().add_feature(
FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new() },
FeatureKind::Import { file: format!("{} independent base", body.name), step: step.into(), components: Vec::new(), skip: Vec::new() },
Some(&feature_name),
)?;
if let Some(f) = scratch.doc_mut().feature_mut(id) {
+1 -1
View File
@@ -16,7 +16,7 @@ use crate::params::{bad, str_, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.insert_design", "Insert Design", insert_design).icon("component").params(
CommandSpec::new("component.insert_design", "Insert Design", insert_design).at("SOLID", "ASSEMBLE").icon("component").params(
"path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \
name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]",
),
@@ -159,7 +159,10 @@ fn inherited_component_appearance_and_legacy_assignment_survive_transfer() {
let mut s = Session::default();
let source = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"}));
s.doc_mut().appearances.components.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7 });
s.doc_mut()
.appearances
.components
.insert(source, solidcraft_doc::appearance::Look { name: "Green".into(), color: [0, 150, 0], opacity: 0.7, pbr: None });
s.refresh();
let look = s.doc.model_body_look(s.model.state().body("Block").unwrap()).unwrap();
run(&mut s, "component.move_bodies", json!({"component":0,"bodies":["Block"]}));
@@ -73,7 +73,7 @@ fn imported(session: &mut Session, step: String, name: &str) -> u64 {
let owner = session.active_component;
let id = session
.doc_mut()
.add_feature(FeatureKind::Import { file: "fixture.step".into(), step: step.into(), components: vec![] }, Some(name))
.add_feature(FeatureKind::Import { file: "fixture.step".into(), step: step.into(), components: vec![], skip: Vec::new() }, Some(name))
.unwrap();
let feature = session.doc_mut().feature_mut(id).unwrap();
feature.body_names = vec![name.into()];
+20
View File
@@ -14,9 +14,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
.noundo()
.doc_only()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint)
.noundo()
.doc_only()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
CommandSpec::new("timeline.delete", "Delete", delete)
@@ -624,6 +626,13 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
merge(&mut v, set, 0);
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
nf.id = f.id;
if let (FeatureKind::Extrude { extent: old, .. }, FeatureKind::Extrude { extent: new, .. }) = (&f.kind, &mut nf.kind)
&& old.direction != solidcraft_doc::Direction::Symmetric
&& new.direction == solidcraft_doc::Direction::Symmetric
&& set.get("extent").and_then(|e| e.get("convention")).is_none()
{
new.convention = solidcraft_doc::ExtrudeConvention::TotalDistance;
}
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
return Err(bad(cmd, "the feature type cannot change"));
}
@@ -860,6 +869,17 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
if let FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } = &old.kind
&& matches!(cid, "solid.extrude" | "surface.extrude")
&& let Some(parameters) = params.as_object_mut()
{
let convention = if cid == "surface.extrude" || extent.direction == solidcraft_doc::Direction::Symmetric {
extent.convention
} else {
solidcraft_doc::ExtrudeConvention::TotalDistance
};
parameters.entry("convention").or_insert_with(|| json!(convention));
}
if let FeatureKind::Revolve { convention, .. } = &old.kind
&& cid == "solid.revolve"
&& let Some(parameters) = params.as_object_mut()
@@ -0,0 +1,143 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::{Document, ExtrudeConvention, FeatureKind};
fn run(s: &mut Session, cmd: &str, params: Value) {
s.execute(cmd, &params).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
fn fixture(settings: Value) -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Square"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
run(&mut s, "sketch.finish", json!({}));
let mut p = json!({"sketch":"Square","distance":"10 mm","direction":"symmetric","operation":"new","name":"Depth","body_name":"Block"});
p.as_object_mut().unwrap().extend(settings.as_object().unwrap().clone());
run(&mut s, "solid.extrude", p);
s
}
fn check(s: &Session, lo: f64, hi: f64) {
assert!(s.model.results.iter().all(|r| r.error.is_none()));
let st = s.model.state();
let body = st.body("Block").unwrap();
let bounds = body.mesh().bounds();
assert!((bounds.min.z - lo).abs() < 1e-7 && (bounds.max.z - hi).abs() < 1e-7, "{bounds:?}");
let m = solidcraft_kernel::measure(&body.body).unwrap();
assert!((m.volume - 100.0 * (hi - lo)).abs() < 1e-7, "{m:?}");
assert!((m.centroid.z - (lo + hi) * 0.5).abs() < 1e-7);
}
#[test]
fn symmetric_extrude_total_distance_is_centered_and_follows_parameters() {
for distance in ["10 mm", "-10 mm"] {
let mut s = fixture(json!({"distance":distance,"from":[0,0,2],"start_offset":"3 mm"}));
check(&s, 0.0, 10.0);
let FeatureKind::Extrude { extent, .. } = &s.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
assert_eq!(extent.convention, ExtrudeConvention::TotalDistance);
let name = s.doc.find_feature("Depth").unwrap().param_names[0].clone();
run(&mut s, "parameters.change", json!({"name":name,"expression":"20 mm"}));
check(&s, -5.0, 15.0);
run(&mut s, "edit.undo", json!({}));
check(&s, 0.0, 10.0);
check(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()), 0.0, 10.0);
}
}
#[test]
fn symmetric_extrude_switch_from_one_side_keeps_total_thickness() {
let mut s = fixture(json!({"direction":"positive","convention":"legacy_per_side"}));
check(&s, 0.0, 10.0);
run(&mut s, "timeline.edit", json!({"feature":"Depth","set":{"extent":{"direction":"symmetric"}}}));
check(&s, -5.0, 5.0);
run(&mut s, "edit.undo", json!({}));
check(&s, 0.0, 10.0);
run(
&mut s,
"timeline.redefine",
json!({"feature":"Depth","command":"solid.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Block"}}),
);
check(&s, -5.0, 5.0);
}
#[test]
fn symmetric_extrude_legacy_missing_marker_preserves_geometry_and_edits() {
let s = fixture(json!({"convention":"legacy_per_side"}));
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
saved["format"] = json!("solidcraft/21");
for f in saved["features"].as_array_mut().unwrap() {
if f["type"] == "extrude" {
f["extent"].as_object_mut().unwrap().remove("convention");
}
}
let mut s = Session::new(Document::from_json(&saved.to_string()).unwrap());
check(&s, -10.0, 10.0);
run(&mut s, "timeline.edit", json!({"feature":"Depth","set":{"extent":{"distance":"12 mm"}}}));
check(&s, -12.0, 12.0);
run(&mut s, "edit.undo", json!({}));
run(
&mut s,
"timeline.redefine",
json!({"feature":"Depth","command":"solid.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Block"}}),
);
check(&s, -10.0, 10.0);
check(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()), -10.0, 10.0);
}
#[test]
fn symmetric_extrude_through_all_keeps_reach_and_two_sides_keep_independent_distances() {
let mut s = fixture(json!({"distance2":"3 mm"}));
check(&s, -3.0, 10.0);
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,100],"body_name":"FarAbove"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,-110],"body_name":"FarBelow"}));
run(
&mut s,
"solid.extrude",
json!({"sketch":"Square","direction":"symmetric","through_all":true,"operation":"cut","targets":["FarAbove","FarBelow"]}),
);
assert!(s.model.state().body("FarAbove").is_none());
assert!(s.model.state().body("FarBelow").is_none());
}
#[test]
fn symmetric_extrude_invalid_convention_is_atomic() {
let mut s = fixture(json!({}));
let before = s.doc.to_json();
for convention in [json!("unknown"), json!(7), Value::Null] {
assert!(s.execute("solid.extrude", &json!({"sketch":"Square","distance":10,"direction":"symmetric","convention":convention})).is_err());
assert_eq!(s.doc.to_json(), before);
}
}
#[test]
fn symmetric_extrude_thin_wall_uses_total_distance_and_to_object_remains_one_sided() {
let s = fixture(json!({"thin":true,"thickness":"2 mm"}));
let st = s.model.state();
let body = st.body("Block").unwrap();
let b = body.mesh().bounds();
assert!((b.min.z + 5.0).abs() < 1e-7 && (b.max.z - 5.0).abs() < 1e-7);
// A centred 2 mm wall around a 10 mm square: (12² - 8²) × 10.
assert!((solidcraft_kernel::measure(&body.body).unwrap().volume - 800.0).abs() < 1e-7);
check(&fixture(json!({"to":[0,0,10]})), 0.0, 10.0);
}
#[test]
fn surface_extrude_shared_extent_retains_its_existing_per_side_semantics() {
let mut s = fixture(json!({}));
run(&mut s, "surface.extrude", json!({"sketch":"Square","distance":"10 mm","direction":"symmetric","name":"Sheet","body_name":"Sheet"}));
let check_sheet = |s: &Session| {
let st = s.model.state();
let bounds = st.body("Sheet").unwrap().mesh().bounds();
assert!((bounds.min.z + 10.0).abs() < 1e-7 && (bounds.max.z - 10.0).abs() < 1e-7);
};
check_sheet(&s);
run(
&mut s,
"timeline.redefine",
json!({"feature":"Sheet","command":"surface.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Sheet"}}),
);
check_sheet(&s);
check_sheet(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()));
}
+26 -5
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use solidcraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul};
use solidcraft_geom::{Mesh, Vec2, Vec3};
use crate::params::bad;
use crate::params::{bad, vec3};
use crate::{Result, Session};
fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
@@ -57,6 +57,22 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6))
}
/// A face given to a command: a point `[x, y, z]` on it (or `{point}`), or its name as
/// `model.faces` lists it, looked up on `body` (any body without one). A name becomes a point on
/// that face, which the feature keeps like a picked one, so it also keeps the face's name.
/// The point is in the frame of the command's other picks: the component's own when
/// they were mapped into it, else the world's.
pub(super) fn face_ref(s: &Session, v: &Value, body: Option<&str>, cmd: &str) -> Result<Vec3> {
if let Some(name) = v.as_str() {
if name.is_empty() || name.len() > 512 {
return Err(bad(cmd, "a face name is 1…512 characters"));
}
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
return solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why));
}
vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it, or its name from model.faces"))
}
/// The body face under a point: (mesh, face index, distance, body).
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
face_at_in(s, p, None).ok().flatten()
@@ -112,8 +128,9 @@ fn xy(v: Vec2) -> Value {
}
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> {
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
/// The sketch is left active.
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2, String)> {
let none = || match face_body {
Some(b) => bad(cmd, format!("no face of `{b}` at `face`")),
None => bad(cmd, "no body face at `face`"),
@@ -183,7 +200,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
// The face's edges came in as linked projections: they follow the face when earlier
// features change, so nothing is copied.
if r.get("projected").is_some_and(|x| !x.is_null()) {
return Ok((sid, plane.to_local(author_inside)));
return Ok((sid, plane.to_local(author_inside), body));
}
for e in &edges {
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
@@ -221,9 +238,13 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
}
};
}
Ok((sid, plane.to_local(author_inside)))
Ok((sid, plane.to_local(author_inside), body))
}
#[cfg(test)]
#[path = "face_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "face_join_tests.rs"]
mod join_tests;
+127
View File
@@ -0,0 +1,127 @@
//! #226: a face's outward extrusion must not join an assembly neighbour it only touches.
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::{Document, FeatureKind};
use solidcraft_geom::Vec3;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|error| panic!("{command}: {error}"))
}
fn assembly(cap_first: bool) -> Session {
let mut s = Session::default();
let wall = json!({"length":40,"width":30,"height":20,"body_name":"Wall"});
let cap = json!({"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"});
for params in if cap_first { [cap, wall] } else { [wall, cap] } {
run(&mut s, "solid.box", params);
}
s
}
fn check_wall(s: &Session, count: usize) {
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
let state = s.model.state();
assert_eq!(state.bodies.len(), count);
let wall = state.body("Wall").unwrap();
let measure = solidcraft_kernel::measure(&wall.body).unwrap();
assert!((measure.volume - 30_000.0).abs() < 1e-6, "{measure:?}");
assert!(measure.bbox.max.dist(Vec3::new(50.0, 30.0, 20.0)) < 1e-9);
assert!(wall.body.contains(Vec3::new(49.0, 15.0, 10.0)));
let cap = solidcraft_kernel::measure(&state.body("Cap").unwrap().body).unwrap();
assert!((cap.volume - 6000.0).abs() < 1e-6);
assert!(cap.bbox.max.dist(Vec3::new(40.0, 30.0, 25.0)) < 1e-9);
}
#[test]
fn face_join_and_press_pull_leave_the_edge_touching_cap_in_either_body_order() {
for cap_first in [true, false] {
for operation in ["join", "auto", "press_pull"] {
let mut s = assembly(cap_first);
let before = s.model.state();
let params = json!({"face":[40,15,10],"distance":10,"operation":operation});
let command = if operation == "press_pull" { "solid.press_pull" } else { "solid.extrude" };
let result = run(&mut s, command, params);
assert_eq!(result["changed_bodies"], json!(["Wall"]));
assert_eq!(result["removed_bodies"], json!([]));
assert!(s.model.state().body("Cap").unwrap().body.same(&before.body("Cap").unwrap().body));
check_wall(&s, 2);
let saved = s.doc.to_json();
run(&mut s, "edit.undo", json!({}));
assert!(s.model.state().body("Wall").unwrap().body.same(&before.body("Wall").unwrap().body));
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), saved);
check_wall(&Session::new(Document::from_json(&saved).unwrap()), 2);
}
}
}
#[test]
fn face_join_respects_explicit_targets_and_participants() {
for key in ["targets", "participants"] {
for all in [false, true] {
let mut s = assembly(!all);
let mut params = json!({"face":[40,15,10],"distance":10,"operation":"join"});
// Joining the wall first permits the cap's full base face to join afterwards.
params[key] = if all { json!(["*"]) } else { json!(["Wall", "Cap"]) };
run(&mut s, "solid.extrude", params);
let state = s.model.state();
assert_eq!(state.bodies.len(), 1, "{key}");
assert!((solidcraft_kernel::measure(&state.body("Wall").unwrap().body).unwrap().volume - 36_000.0).abs() < 1e-6);
}
}
}
#[test]
fn face_join_maps_the_picked_instance_body_and_point_together() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Repeated"}))["component"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Wall"}));
run(&mut s, "component.activate", json!({"component":0}));
let occurrence = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[100,0,0]}))["occurrence"].as_u64().unwrap();
run(&mut s, "component.activate", json!({"component":component,"occurrence":occurrence}));
let world = s.world_state();
let body = world.bodies.iter().find(|body| body.body.contains(Vec3::new(139.0, 15.0, 10.0))).unwrap().name.clone();
assert_ne!(body, "Wall", "exercise an instance body name");
let result = run(&mut s, "solid.extrude", json!({"face":[140,15,10],"face_body":body,"distance":10,"operation":"join"}));
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
assert_eq!(targets, &["Wall"]);
let world = s.world_state();
assert_eq!(world.bodies.len(), 2);
for point in [Vec3::new(49.0, 15.0, 10.0), Vec3::new(149.0, 15.0, 10.0)] {
assert!(world.bodies.iter().any(|body| body.body.contains(point)), "{point:?}");
}
}
#[test]
fn face_join_of_a_step_import_keeps_all_32_neighbours_unchanged() {
let mut source = assembly(true);
for i in 0..31 {
run(&mut source, "solid.box", json!({"corner":[100+20*i,100,0],"length":5,"width":5,"height":5,"body_name":format!("Distant{i}")}));
}
let step = solidcraft_io::export(&source.model.state(), &[], solidcraft_io::Format::Step, "Original assembly").unwrap();
let imported = solidcraft_io::step_import_feature(&step, "original-assembly.step").unwrap();
let mut doc = Document::new("Imported assembly");
let feature = doc.add_feature(imported.kind, None).unwrap();
doc.feature_mut(feature).unwrap().body_names = imported.body_names;
let mut s = Session::new(doc);
assert_eq!(s.doc.features.len(), 1);
let before = s.model.state();
let result = run(&mut s, "solid.extrude", json!({"face":[40,15,10],"distance":"10 mm","direction":"positive","operation":"join"}));
assert_eq!(result["changed_bodies"], json!(["Wall"]));
check_wall(&s, 33);
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
check_wall(&reopened, 33);
for body in before.bodies.iter().filter(|body| body.name != "Wall") {
let after = s.model.state();
assert!(body.body.same(&after.body(&body.name).unwrap().body), "{}", body.name);
let cold = reopened.model.state();
let original = solidcraft_kernel::measure(&body.body).unwrap();
let restored = solidcraft_kernel::measure(&cold.body(&body.name).unwrap().body).unwrap();
assert!((original.volume - restored.volume).abs() < 1e-9, "{}", body.name);
assert!(original.bbox.min.dist(restored.bbox.min) < 1e-9 && original.bbox.max.dist(restored.bbox.max) < 1e-9);
}
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
assert_eq!(targets, &["Wall"]);
}
+124 -28
View File
@@ -1,7 +1,9 @@
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
use serde_json::{Value, json};
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, ThinSide, ThinWall, expr::Kind, point_expr};
use solidcraft_doc::{
AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
};
use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
@@ -13,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
@@ -21,7 +23,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"),
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
@@ -67,11 +69,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.params("faces: [[x,y,z] points on the faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
.params("faces: [[x,y,z] points on the faces, or face names from model.faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
@@ -79,11 +81,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
.params("faces: [[x,y,z] points on the faces to remove, or face names from model.faces]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
CommandSpec::new("solid.draft", "Draft", draft)
.at("SOLID", "MODIFY")
.icon("draft")
.params("faces: [[x,y,z]]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
.params("faces: [[x,y,z] points, or face names from model.faces]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
@@ -96,7 +98,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("split")
.params(
"faces: [[x,y,z] points on the faces to split]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
@@ -137,9 +139,16 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
Some((ss.plane.to_world(q), ss.plane.normal()))
}
/// How far past the profile [`auto_operation`] looks for material it starts in.
const START_PROBE_MM: f64 = 0.1;
/// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes
/// into a body, otherwise `join` when the active component has a solid. Keeping an
/// addition separate requires an explicit `new` operation.
///
/// "Goes into a body" is decided where the extrude starts: just past the profile in the
/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far
/// past a body's far side read as a Join (#1257).
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?;
let dir = str_(p, "direction").map(str::to_ascii_lowercase);
@@ -161,10 +170,13 @@ pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
return Some("new");
}
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
// A step into whatever is right in front of the profile, short of the far end.
let near = |d: f64| d.signum() * (d.abs() * 0.5).min(START_PROBE_MM);
let into = |d: f64| inside(base + n * near(d)) || inside(base + n * (d * 0.5));
if symmetric {
return Some(if inside(base + n * (d.abs() * 0.5)) || inside(base - n * (d.abs() * 0.5)) { "cut" } else { "join" });
return Some(if into(d.abs()) || into(-d.abs()) { "cut" } else { "join" });
}
if inside(base + n * (d * 0.5)) {
if into(d) {
Some("cut")
} else {
// A profile can meet material along its side, or only around part of a
@@ -435,6 +447,20 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
return Err(EngineError::Other(format!("`{key}` must list existing bodies")));
}
// A cut or intersect made where the active component has no body of its own (a sketch in
// the assembly's root, cutting through the parts, #1234) would have nothing to work on: it
// takes part in every body instead, which it changes only where it meets them.
let participants = if participants.is_empty()
&& !primitive
&& matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
&& {
let st = s.model.state();
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
} {
vec!["*".to_string()]
} else {
participants
};
if matches!(kind, FeatureKind::ConstructionPlane { .. })
&& let Some(n) = name
{
@@ -502,12 +528,12 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.extrude";
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }))
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
}
None => (feature_sketch(s, p, cmd)?, None),
None => (feature_sketch(s, p, cmd)?, None, None),
};
let extent = extent_param(s, p, cmd)?;
let thin = match bool_(p, "thin") {
@@ -520,6 +546,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
_ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")),
_ => None,
};
let operation = match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
};
let mut targets = string_list(p, "targets");
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
// A face's outward extension joins that body. Assembly neighbours can touch only an
// edge of the tool: trying to join them first fails, or absorbs unrelated parts (#226).
// Store the resolved target so recomputes and reopening keep the same scope.
targets.extend(face_body);
}
let kind = FeatureKind::Extrude {
sketch,
profiles: match face_profile {
@@ -528,11 +565,8 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
None => profiles(p, cmd)?,
},
extent,
operation: match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
},
targets: string_list(p, "targets"),
operation,
targets,
thin,
};
add_feature(s, p, kind)
@@ -622,8 +656,15 @@ pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent>
if let Some(d) = &start_offset {
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
}
let convention = match p.get("convention") {
// Surface Extrude shares Extent but retains its existing per-side distance semantics.
None if cmd == "surface.extrude" => solidcraft_doc::ExtrudeConvention::LegacyPerSide,
None => solidcraft_doc::ExtrudeConvention::TotalDistance,
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be total_distance or legacy_per_side"))?,
};
Ok(Extent {
distance,
convention,
direction,
distance2,
start_offset,
@@ -640,7 +681,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
// A planar body face revolves like a profile (its edges projected into a sketch on it).
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
let (id, inside, _) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
s.active_sketch = None;
(id, Some(ProfileSel::Points { points: vec![inside] }))
}
@@ -1323,7 +1364,7 @@ fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_face";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let body = str_(p, "body").map(str::to_string);
let xy = || solidcraft_doc::PlaneRef::Origin { name: "XY".into() };
let kind = if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
@@ -1542,17 +1583,17 @@ fn sketch_hole_points(s: &Session, sketch: u64) -> Vec<String> {
ss.sketch.points.iter().enumerate().filter(|(i, p)| p.id != "origin" && !ends.contains(i)).map(|(_, p)| p.id.clone()).collect()
}
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points"))?;
fn face_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points or face names"))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "select 1…1000 faces"));
}
a.iter().map(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it"))).collect()
a.iter().map(|v| super::face::face_ref(s, v, str_(p, "body"), cmd)).collect()
}
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.shell";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default();
@@ -1574,7 +1615,7 @@ fn shell(s: &mut Session, p: &Value) -> Result<Value> {
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.draft";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let neutral = plane_param(s, p.get("neutral"), cmd)?;
@@ -1604,6 +1645,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
if path.is_empty() {
return Err(bad(cmd, "`path` must list curve ids"));
}
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
add_feature(
s,
p,
@@ -1612,12 +1654,58 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
profiles: profiles(p, cmd)?,
path_sketch,
path,
orientation,
twist,
scale,
operation: operation(p, cmd)?,
targets: string_list(p, "targets"),
},
)
}
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<String>, Option<String>)> {
let orientation = match p.get("orientation") {
None | Some(Value::Null) => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
Some(v) => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
if !(d.len() > 1e-9) {
return Err(bad(cmd, "the `orientation` direction must not be zero"));
}
SweepOrientation::Direction(d)
}
};
let twist = match p.get("twist") {
None | Some(Value::Null) => None,
Some(_) => {
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
}
Some(t)
}
};
let scale = match p.get("scale") {
None | Some(Value::Null) => None,
Some(_) => {
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
if !(v.is_finite() && range.contains(&v)) {
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
}
Some(k)
}
};
Ok((orientation, twist, scale))
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
@@ -1715,14 +1803,14 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(p, "face.delete")?;
let faces = face_points(s, p, "face.delete")?;
let heal = bool_(p, "heal").unwrap_or(true);
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string), heal })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.offset_face";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::OffsetFace { faces, distance, body: str_(p, "body").map(str::to_string) })
@@ -1736,6 +1824,10 @@ mod patterns_tests;
#[path = "revolve_convention_tests.rs"]
mod revolve_convention_tests;
#[cfg(test)]
#[path = "sweep_options_tests.rs"]
mod sweep_options_tests;
#[cfg(test)]
#[path = "offset_topology_tests.rs"]
mod offset_topology_tests;
@@ -1770,3 +1862,7 @@ mod move_in_sketch_tests;
#[cfg(test)]
#[path = "thin_extrude_tests.rs"]
mod thin_extrude_tests;
#[cfg(test)]
#[path = "extrude_convention_tests.rs"]
mod extrude_convention_tests;
+5 -5
View File
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("move")
@@ -316,17 +316,17 @@ fn infer_emboss_selection(
}
Ok(chosen)
}
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
fn face_points(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z] or face names")))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, format!("`{k}` must list 1…1000 points")));
}
a.iter().map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y, z] points")))).collect()
a.iter().map(|v| super::face::face_ref(s, v, str_(p, "body"), cmd)).collect()
}
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.replace_face";
let faces = face_points(p, "faces", cmd)?;
let faces = face_points(s, p, "faces", cmd)?;
for f in &faces {
if super::face::face_normal(s, *f).is_none() {
return Err(bad(cmd, "not supported yet: replacing a face that is not planar"));

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