Compare commits

..
29 Commits
Author SHA1 Message Date
Z User a824c1eb75 [IMP] OneCAD rebrand: OneCAD wordmark + direct-to-workspace launch, no splash hold, no external links (docs/GitHub removed, local reports), all visible strings + locales OneCAD 2026-10-18 17:43:10 +00:00
Z User ac05e26934 [FIX] OneCAD web: Odoo Documents integration sources (services_odoo.rs, odoo glue, build script); wasm32 picker freeze fixed with web_time::Instant 2026-10-18 17:43:03 +00:00
H. Conrad 4e0601611c Fix id overflow (#1589)
Thanks @Ian-cmd-ops.
2026-10-18 06:53:57 -04:00
Brazz 07708a4e2a Extrude: a taper composes with a start offset (#2312) (#2313)
Thanks @acbrazzl.
2026-10-18 06:18:43 -04:00
Artem Sushchev a4c4b411e5 Combine: a cut keeps a body joined from separate pieces in one body (#2247)
Thanks @H1D.
2026-10-18 06:02:44 -04:00
franco michetti 5eb7640b54 Agent chat: pop out into its own window, dock back (#1745)
Thanks @onembyte.
2026-10-18 05:59:30 -04:00
003abc2149 Issue forms and needs-info: ask short bug reports for steps to reproduce (#2416)
* Triage bot: needs-info for short bug reports without steps

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Issue forms for bugs and feature requests; document needs-info

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 05:44:48 -04:00
136a3e9a2f Report an Issue: open issues on tangency-labs/solidcraft (#2417)
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 05:44:23 -04:00
DuBearte e70bd52409 Extrude: a side drawn in collinear pieces is one face (#1788) (#1949)
Thanks @DuBearte.
2026-10-18 05:43:23 -04:00
NOWN-DEV 132b63d24d Kernel profile: a thread's faceted cut and the reference display mesh are timed (#1833) (#2095)
Thanks @NOWN-DEV.
2026-10-18 05:43:05 -04:00
bherbruckandClaude Opus 5.5 72b7fc385b Fix red main: record robustness seeds 13, 67 and 94 after #2064 (#2396)
#2064 (multi-profile extrusion) merges coplanar faces the boolean used to
leave split. Seeds 67 and 94 change face/edge counts at the same volume;
seed 13 has four fewer edges before its random fillet, which then picks
different edges by index. Every step still passes the validity and
volume-direction checks. Pull request CI runs every 8th seed only, so
main's full run was the first to see these.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 05:38:41 -04:00
292747e50b Persistent face names: keep exact history when a prism boolean meets stacked pieces of one cylinder (#1209) (#2205)
* claim: #1209

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011YdMi9BDdjWZ8twfeBHAXz

* Prism boolean: a curved edge at one level crosses the walls there only (#1209)

The prism boolean sews its result slab by slab and keeps, for each section segment, the operand
wall it was read beside; its history is built from those tags. `Stations::new` took every curved
edge between two walls as crossing them at every level, so where two pieces of one cylinder are
stacked (an arm's round end under a boss of that radius) the circle between them cleared the
wall tags of every slab. `prism_lineage::sewn` then gave up, the join or cut came out with no
history, and every face of the body was named from geometry (`exact: false`).

A curve whose control points lie at one level (a B-spline, or a NURBS curve with positive
weights, which lies in their convex hull) is at that level: it crosses the walls there only.
Any other curve still spans every level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011YdMi9BDdjWZ8twfeBHAXz

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-18 05:33:30 -04:00
2e4266cb0a Triage bot and PR-based claims (#2397)
* Claim bot: a PR referencing an issue claims it

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Triage bot: label new issues and PRs, /triage, backfill

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Docs: a draft PR is the claim; labels and triage

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

* Triage bot: start a runner only for events it acts on

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TPWBBSRPfPYWgrrhbznewR

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 05:17:21 -04:00
Lasse Lian b685b7301f Preferences: reverse horizontal pan direction (#2007) (#2047)
Thanks @lasselian.
2026-10-18 03:12:23 -04:00
cronos 8f768b65c8 Avoid per-frame mesh copies in both timeline layouts (#1907)
Thanks @crono2250.
2026-10-18 02:34:05 -04:00
JIJINBEI 932efbf341 Sketch drag: move only what the constraints need (#2060)
Thanks @jijinbei.
2026-10-18 02:23:24 -04:00
Karl Von Richter b8d7b42808 contributing(docs): clarify draft PR and bot claims (#2317)
Document the maintainer-confirmed workflow: open a draft PR to establish ownership, then comment /claim to mark the issue as taken. Align AGENTS.md and CONTRIBUTING.md and distinguish the existing takeover policy from the bot's automatic cleanup deadlines.

Signed-off-by: Karl Von Richter<[email protected]>
2026-10-18 08:11:29 +02:00
cronos a8e8d82f90 Add regression coverage for mouse picking during 3D mouse navigation (#1786)
Thanks @crono2250.
2026-10-18 01:33:09 -04:00
DuBearte 33cd031701 Sketch: the arc's sweep box no longer covers the end-point pick (#2201) (#2242)
Thanks @DuBearte.
2026-10-18 01:10:53 -04:00
selune 6422ef84d5 Drawing: share length display formatting (#2054) (#2086)
Thanks @0xlillith.
2026-10-18 00:29:15 -04:00
selune 7c9f8a5895 Drawing: validate section geometry contracts (#2053) (#2085)
Thanks @0xlillith.
2026-10-18 00:29:12 -04:00
bherbruckandClaude Opus 5.5 36b6496ad5 CI: give fork builds on the untrusted pool 90 minutes (#2339)
Fork Build tests jobs were hitting the 45-minute limit while uploading their archive, after
the build had finished, and every one had to start over.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-18 00:28:18 -04:00
48986041ed Add complete Italian UI localization (#1941)
* Add complete Italian UI localization

Translate all 1509 English catalog entries originally, register Italiano, guard complete coverage and exercise Italian labels and UI rendering.

Co-authored-by: Copilot App <[email protected]>

* i18n: Italian is a partial catalog

it.json lacks 19 messages main has gained since this branch began, so as a
complete catalog it would fail every_complete_catalog_carries_every_english_message.
Like the other community languages, Italian is now partial: its gaps fall
back to English and new labels don't require an Italian string. The docs
line goes back to main's wording, with Italian among the partial catalogs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: Copilot App <[email protected]>
Co-authored-by: Ben Herbruck <[email protected]>
Co-authored-by: bherbruck <[email protected]>
Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 22:28:04 -04:00
bherbruckandClaude Opus 5.5 c39bd23d07 CI: run all four test shards on the trusted self-hosted runners (#2220)
The trusted runs' test archive is still built on GitHub's ubuntu-24.04
runners; minisforum's newer glibc runs it unchanged, so shards 3-4 can join
1-2 on the self-hosted runners.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 22:11:35 -04:00
bherbruckandClaude Opus 5.5 a33c5a7861 CI: restore main's Rust cache in fork pull request jobs on the untrusted runners (#2254)
Fork pull requests now run on the untrusted self-hosted runners, which start
every job empty. Let them restore main's rust-cache like GitHub's runners do
(save-if stays main only, so a fork job never writes a cache).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-17 22:11:31 -04:00
5c95b0496d Spanish (es-ES): catch up with en.json (#2101)
Brings assets/locales/es.json back to full coverage of en.json: 60 new
messages translated (motion study, CAM, DXF export progress, sweep rails,
threads, grid follows the view) plus 13 terminology revisions (Loft,
Boss, timeline marker, viewport, ellipsis), all following the glossary.

It also unifies the snap label, as asked in the review of this pull
request: ui.snap (Snap) and cmd.sketch_snap (Snap Inference) now share a
single name, "Referencia a objetos".

Built on top of current main, so the three messages upstream had already
translated in es.json (Direction, In plane, Normal to sketch) are kept.

Co-authored-by: carlymx <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-17 21:53:14 -04:00
72aa77dabd French: catch up with main (10 messages) (#2203)
The Clearance report, Explode Text, the continuity legend, the Grid follows
the view preference and inspect.silhouette, so fr.json covers en.json again
(1518/1518).

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-17 21:51:06 -04:00
243db6cb8f README: list the crash_reports MCP tool (#1821) (#1830)
* claim: #1821

* claim: #1821

* docs: README lists the crash_reports MCP tool (#1821)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-17 21:48:19 -04:00
83c31d77c4 surface.boundary: surfaces through boundary curves and curve networks (#763) (#2129)
* feat(geom): surfaces through curve networks, and spline spaces to build them

Adds spline_space (clamped B-spline spaces, Hermite interpolation, exact
embedding by Bézier degree elevation and knot insertion, a monotone cubic
for reparameterising) and network (loft, Coons and Gordon surfaces through
curves in one or two directions, with G1/G2 cross-boundary conditions
against a neighbouring face). Part of #763.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(kernel): boundary surfaces from sketch curves, 3D curves and edges

kernel::boundary_network turns sketch chains, 3D sketch polylines and edge chains
into network curves, gives G1 frames of the faces along open edges
(EdgeFaces), builds the B-spline face (sides split at chain vertices so a
join shares the body's edges) and joins it into a surface body by stitching.
G2 is refused for now (not supported yet). Part of #763.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(kernel): second derivatives of B-spline surfaces in the vendored truck-geometry

BSplineSurface::uuder and vvder multiplied the second differences by p
instead of p·(p − 1), as BSplineCurve::der2 does, so every surface of
degree p ≥ 3 (NURBS too, through its homogeneous surface) reported
1/(p − 1) of its second derivative: a cubic surface's curvature read as
half. This is why a G2 boundary surface on a cylinder measured half the
curvature it carries (#763). New tests hold both to finite differences
for degrees 2 to 5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(kernel): G2 boundary surfaces against a body's face

The network always carried the face's curvature across a G2 edge; the
refusal came from measuring it with truck's halved surface second
derivatives (fixed in the previous commit). EdgeFaces now reads the
face's second fundamental form from the exact second derivatives
instead of differencing the normal. A new test holds the curvature
across a cylinder's edge to the cylinder's within 0.1%, also with a
sheared far curve; G3 is refused (a network holds up to G2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(kernel): a boundary patch with straight corners measures its own area

A stadium's arcs meet its lines at 180°, so the Coons patch is singular
at its corners. From such a corner the reference mesher's boundary walk
ran onto the B-spline's extrapolation past its domain (a planar spline
passes through every point of its plane there), and the face measured
289 mm² for its 158.5 (#763). The vendored mesher's bounded projection
(nearest_projection, #2043) keeps the walk on the domain; this holds it
there, by the faces, the measuring mesh and the display mesh. The
display path (#1653) was never affected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(geom,kernel): three-sided boundary surfaces (one side collapsed to a point)

A network curve may be a PointCurve: the apex where the two sides beside
it meet. The curves through it cross it at the point itself, its row of
the net collapses there, and the kernel's face leaves that side out of
its loop (three edges, one vertex at the apex). One point per patch, as
a side; refused elsewhere, when it moves, or with a continuity
condition. Tests: a triangle and a quarter disc filled flat with their
analytic areas (40 mm², 25π), by geom and by the kernel's faces and
meshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(doc): boundary surfaces through curve networks and loops of three or four

FeatureKind::SurfaceNetwork saves a network as `u` and `v` curves, each
a sketch chain (planar or 3D), a chain of a body's edges with an
optional G1/G2 continuity, or a point (the apex of a three-sided
patch), and an `operation` that can join the surface into the body of
its edges. A SurfaceBoundary of three or four sketch curves, refused
until now, builds as the flat Coons patch of its loop (three collapse
the side opposite the first curve). The format change is one pending
file; feature-walk goldens record the new variant's sketches, bodies
and points.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(engine): surface.boundary through loops of three or four and curve networks

The command keeps its loop form (two to four curves of one sketch; three
and four now build the flat patch of the loop, crown refused there) and
gains a network form: `u` and `v`, each curve a sketch curve id, a
{sketch?, curves} chain (planar or 3D), a {body, edges, continuity?}
edge chain (g1/g2 with the face beside it) or a {point} for a
three-sided patch, with `operation` new or join. Edits may move between
the two forms (timeline.redefine accepts the command now that it has a
dialog).

Tests: analytic areas of loop patches and of a hyperbolic paraboloid
bounded by four 3D lines (every mesh vertex on z = xy/10), a loft
across two sketches, G1 and G2 measured across a cylinder's edge with
inspect.continuity (angle < 0.1°; curvature jump 1e-16 with G2 against
0.027 with G1), 26 hostile networks refused without panicking, and u/v
added to hostile_params_never_panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* feat(ui): Boundary Surface dialog

Curves and Cross curves take sketch curves and edges (picks that meet
end to end in one sketch or body are one chain); Continuity and
Operation (New Body or Join). Two to four sketch curves of one sketch
with nothing across are the loop patch; anything else is a network.
Edit Feature brings either back (per-side orders, crown and stations
the dialog does not show are kept). Strings in en, ja and zh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* docs: ROADMAP M8 and the bridge patch's notes for boundary networks (#763)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* test(oracle): a boundary surface through a loop, thickened (#763)

Case 83: four sketch curves (a square's three sides and a half circle
for its top) make one boundary surface, thickened 2 mm; analytic values
from tools/oracle-analytic/cases_surfaces.py (V = 2A, area 2A + 2P,
centroid by moments). Recipes gain boundary_surface, thicken and remove.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

* refactor: boundary surfaces, review cleanups

Project onto the face once per derivative evaluation (the frame is
passed to natural() and reused by scale()), keep each track's
finite-difference step instead of rescanning its table, export the
kernel's edge-pick and chain caps and the default station count for
the engine, doc and dialog to share, map TooLarge spline errors to
KernelError::TooLarge, reuse the engine's operation parser, and skip
the dialog's sketch lookup when the picks are no loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
2026-10-17 21:47:38 -04:00
141 changed files with 13166 additions and 752 deletions

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
name: Bug report
description: Something in SolidCraft does the wrong thing
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment.
- type: textarea
id: what
attributes:
label: What happened
description: What did you expect, and what happened instead?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: |
What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
placeholder: |
1. Sketch a circle on the XY plane
2. Extrude it 10 mm
3. Fillet the top edge with 6 mm
validations:
required: true
- type: input
id: version
attributes:
label: SolidCraft version and OS
description: Help → About shows the version.
placeholder: 0.15.0, Windows 11
validations:
required: true
- type: textarea
id: media
attributes:
label: Screenshots or recording
description: |
For anything you can see in the app, a screenshot or short recording of a small example helps the most. Please leave out private designs and personal details.
-23
View File
@@ -1,23 +0,0 @@
---
name: Bug report
about: Help reproduce and fix a SolidCraft problem
title: ''
labels: ''
assignees: ''
---
## Behavior
What did you expect, and what happened instead?
## Reproduction
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
## Screenshots of the behavior (when observable)
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
+2
View File
@@ -1,3 +1,5 @@
# Blank issues stay on: the app's Report an Issue opens issues/new?title=…&body=… with the report
# filled in (crates/ui-egui/src/issue_report.rs), which GitHub drops when blank issues are off.
blank_issues_enabled: true
contact_links:
- name: Questions and chat
+16
View File
@@ -0,0 +1,16 @@
name: Feature request
description: Something SolidCraft should do
labels: [enhancement]
body:
- type: textarea
id: goal
attributes:
label: What are you trying to do
description: The part or workflow you have in mind, and where SolidCraft gets in the way today.
validations:
required: true
- type: textarea
id: idea
attributes:
label: How you imagine it working
description: Optional. Describe the behaviour in your own words; please don't paste another product's documentation.
+18 -13
View File
@@ -19,7 +19,7 @@
# of this repository (people with write access; fork pull requests never run on this machine).
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
# the reference tests and test shards 1-2 on the self-hosted runners (`self-hosted-fast`, warm
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
# `self-hosted-untrusted` runners (no warm caches); everything else runs on GitHub's runners.
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
@@ -186,8 +186,9 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
# The self-hosted runner keeps its build directory and Cargo home instead.
- if: runner.environment == 'github-hosted'
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: repo-checks
@@ -210,8 +211,8 @@ jobs:
run: cargo xtask web-offline-test
- name: Affected-package selection self-test
run: python3 -I scripts/ci/test_affected.py
- name: Claim bot tests
run: node --test scripts/claim/*.test.js
- name: Claim and triage bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
clippy:
name: Native Linux Clippy
@@ -227,8 +228,9 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
# The self-hosted runner keeps its build directory and Cargo home instead.
- if: runner.environment == 'github-hosted'
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-clippy
@@ -248,7 +250,8 @@ jobs:
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
timeout-minutes: 45
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 90 || 45 }}
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -290,7 +293,7 @@ jobs:
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: [changes, build-tests]
runs-on: ${{ (needs.changes.outputs.local == 'true' && matrix.shard <= 2) && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -451,8 +454,9 @@ jobs:
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh doc-tests
- uses: dtolnay/rust-toolchain@stable
# The self-hosted runner keeps its build directory and Cargo home instead.
- if: runner.environment == 'github-hosted'
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: doc-tests
@@ -501,8 +505,9 @@ jobs:
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
run: scripts/ci/warm-target.sh oracle
- uses: dtolnay/rust-toolchain@stable
# The self-hosted runner keeps its build directory and Cargo home instead.
- if: runner.environment == 'github-hosted'
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
+10 -2
View File
@@ -1,10 +1,15 @@
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
# frees claims nobody is working on. The logic is in scripts/claim/claim.js (tested in ci.yml).
# frees claims nobody is working on. A pull request that references an issue (`<summary> (#N)`,
# `Fixes #N`) claims it too. The logic is in scripts/claim/claim.js (tested in ci.yml).
name: Claim
on:
issue_comment:
types: [created]
# pull_request_target so PRs from forks can label and assign. It runs the base branch's
# scripts/claim and only reads the PR's title, body and author; the PR's code is never checked out.
pull_request_target:
types: [opened, reopened]
schedule:
- cron: '17 5 * * *'
workflow_dispatch:
@@ -16,7 +21,7 @@ permissions:
# One run at a time per issue, so two simultaneous /claim comments can't both win.
concurrency:
group: claim-${{ github.event.issue.number || 'sweep' }}
group: claim-${{ github.event.issue.number || github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
@@ -38,6 +43,9 @@ jobs:
const claim = require('./scripts/claim/claim.js');
if (context.eventName === 'issue_comment') {
await claim.handleComment({ github, context });
} else if (context.eventName === 'pull_request_target') {
const claimed = await claim.handlePull({ github, context });
core.info(`Claimed: ${claimed.join(', ') || 'none'}`);
} else {
const released = await claim.sweep({ github, context });
core.info(`Released: ${released.join(', ') || 'none'}`);
+86
View File
@@ -0,0 +1,86 @@
# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports
# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill
# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml).
name: Triage
on:
issues:
types: [opened, labeled, edited]
issue_comment:
types: [created]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/triage and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
schedule:
- cron: '41 5 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Only report what would be labelled'
type: boolean
default: true
permissions:
contents: read # to check out scripts/triage
issues: write
pull-requests: write
concurrency:
group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }}
cancel-in-progress: false
jobs:
triage:
name: Triage bot
runs-on: ubuntu-24.04
timeout-minutes: 30
# Filtered here so the common events (every comment, every label) start no runner.
if: >-
(github.event_name == 'issue_comment' &&
github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) ||
((github.event_name == 'issue_comment' || github.event.action == 'edited') &&
contains(github.event.issue.labels.*.name, 'needs-info') &&
github.event.sender.login == github.event.issue.user.login) ||
(github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority:') &&
contains(github.event.issue.labels.*.name, 'needs-triage')) ||
(github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited')
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/triage
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DRY_RUN: ${{ inputs.dry_run }}
with:
script: |
const triage = require('./scripts/triage/triage.js');
const info = require('./scripts/triage/needs-info.js');
const event = context.eventName;
const action = context.payload.action;
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
const th = (...names) => names.map((data) => ({ data, header: true }));
if (event === 'issue_comment') {
core.info(await triage.handleComment({ github, context }));
await info.handleReply({ github, context });
} else if (event === 'issues' && action === 'labeled') {
await triage.handleLabeled({ github, context });
} else if (event === 'issues' && action === 'edited') {
await info.handleReply({ github, context });
} else if (event === 'schedule') {
const closed = await info.sweep({ github, context });
core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`);
} else if (event === 'workflow_dispatch') {
const dryRun = process.env.DRY_RUN !== 'false';
const plan = await triage.backfill({ github, context, dryRun });
const asks = await info.backfill({ github, context, dryRun });
await core.summary
.addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`)
.addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])])
.addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`)
.addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])])
.write();
} else {
core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`);
if (event === 'issues') await info.handleOpened({ github, context });
}
+1
View File
@@ -7,3 +7,4 @@
/.claude/worktrees/
/target-snapshot
__pycache__/
dist/
+5 -1
View File
@@ -132,9 +132,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
issue wins**; if yours lost, close it. A comment or file edit alone does not replace the draft PR.
- After opening the draft PR, comment `/claim` on the issue so the bot records the claimant and
adds the `in progress` label. **Do both:** the draft PR establishes ownership and `/claim` marks
the issue as taken. Reference `#N` in the PR so the bot can find it.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
This takeover policy is separate from the bot's automatic cleanup deadlines in CONTRIBUTING.md.
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
and wait while it is over 24; build with `nice`.
+2 -1
View File
@@ -93,6 +93,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | 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/gl.json | Pablo López (pablopeza) | Galician translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | 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 |
@@ -462,7 +463,7 @@ copied into SolidCraft.
| 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; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) |
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters; surface second derivatives (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
+2 -1
View File
@@ -129,7 +129,8 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
issue wins**; if yours lost, close it. Opening the PR is the claim: the bot assigns you and
labels the issue `in progress` (or says on your PR who holds it). Don't claim by editing files.
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim (you may start from its branch).
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
+30 -4
View File
@@ -258,6 +258,10 @@ and a fresh CI run:
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
on the other one).
- The bot records the claim when the PR is opened: it assigns you, adds `in progress` and
says so on the issue. It reads `#N` in the PR title and `Fixes #N` in the body. If someone
already holds the issue, or an older open PR references it, it says so on your PR instead.
- Want to stake a claim before you have a branch? Comment `/claim` on the issue (below).
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
comment on it first, then open your own claim.
- Mark the PR ready for review when it is done.
@@ -267,10 +271,11 @@ and a fresh CI run:
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
Commenting `/claim` assigns you, adds the `in progress` label, and replies with its automatic cleanup
deadline. It frees a claim with no linked PR after 72 hours, or a claim with a PR after 14 days
without pushes. These automatic cleanup deadlines are separate from the daily progress and
72-hour takeover policy above. A draft PR that references the issue claims it the same way.
`/unclaim` releases the bot claim early (only you or a maintainer can). Issues a maintainer has
taken are never freed automatically.
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
@@ -299,6 +304,27 @@ owning one area, with a human setting direction and reporting bugs. Their workin
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
## Labels and triage
The triage bot labels every new issue and PR (`.github/workflows/triage.yml`). It only adds labels;
correct them freely and it won't put them back.
- **PRs** get `area:*` from the paths they change (`crates/kernel` and `crates/geom` →
`area:kernel`, `crates/ui-egui` and the apps → `area:ui`, `.github/`, `scripts/`, `docs/` →
`area:infra`, and so on).
- **Issues** get `bug` (in-app reports and the bug template), `enhancement` (feature requests),
`priority:P1` for crashes, and an `area:*` from the title or the reported command. Until it has
a priority, an issue carries `needs-triage`; setting a priority removes it.
- **Maintainers** comment `/triage area:kernel P1` (any existing label; `P0`–`P3` for a
priority, which replaces the old one; `-label` removes one) to sort an issue or PR.
- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for
the steps (and the version, when it's missing). An app report that arrived empty is asked to
paste the report the app copied to the clipboard. The reporter's reply, or an edit that adds the
steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be
reopened any time. Feature requests, app reports with their details, and maintainers' issues
are never asked.
- `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/solidcraft/issues?q=is%3Aopen+label%3Aneeds-triage).
## Where help is wanted most
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://tangency-labs.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/tangency-labs/solidcraft/issues/1597).
Generated
+1
View File
@@ -4278,6 +4278,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"web-time",
]
[[package]]
+2 -2
View File
@@ -289,8 +289,8 @@ A script is a JSON list of commands, the same ones the UI runs:
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
`save`. Tool calls reach files only in the open design's folder (when it lies inside your home
`set_parameter`, `screenshot` (returns the image), `export`, `crash_reports`, `undo`, `redo`,
`new_design`, `open`, `save`. Tool calls reach files only in the open design's folder (when it lies inside your home
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
configuration and an example session: [docs/mcp.md](docs/mcp.md).
+32
View File
@@ -0,0 +1,32 @@
# OneCAD rebrand map (identity layer)
OneCAD is the OneBSpace fork of SolidCraft. Since the 20.0.1.0.6 build the rebrand is
**source-level** for everything the user can see or click; only the compatibility layer keeps
upstream names (so `.solidcraft` files, the `window.solidcraft` JS API and the crate layout stay
upstream-compatible). Files tagged `OneBSpace (OneCAD)` are downstream-only deltas.
## Rebranded in source (user-visible)
| Layer | Change |
|---|---|
| App bar | upstream vector mark dropped; `OneCAD` wordmark text (`toolbar.rs`), shown in every width |
| Start page | title `SolidCraft` → `OneCAD`; the outbound links (Documentation, Report an Issue, What's New) are removed (`home.rs`) |
| Launch flow | the start page no longer shows on launch — the app opens straight into the workspace (`main.rs`); the house button still shows Home |
| Loading splash | upstream `loading-logo.svg` image is not shipped; the splash is a plain `OneCAD` wordmark (`index.html` + `loading.css`) and the 3.6 s minimum hold is gone (`LOADING_MIN_MS = 0`) |
| Help menu | `Documentation` (docs.solidcraft.io) and `Report an Issue…` (GitHub draft) items removed; About / Keyboard Shortcuts / Crash Reports stay |
| About window | `About OneCAD`, OneCAD description, OneCAD diagnostics header |
| Issue/crash reports | fully local: no GitHub URL is opened anywhere; "Open GitHub draft" replaced by a clipboard `Copy report` (`issue_report.rs`); the app-bar bug button tooltip no longer mentions GitHub |
| Visible strings | status lines, recovery dialog, clipboard text (`OneCAD sketch geometry: …`), AI-chat errors, settings copy — `SolidCraft` → `OneCAD` in code literals and in every `assets/locales/*.json` value |
| Web page | `<title>OneCAD`, social-card meta and PWA manifest/icons dropped from the bundle (as before) |
## Stays upstream (compatibility & scale)
- **Format tag `solidcraft/N`** inside the `.solidcraft` ZIP container and the `.solidcraft`
extension (the one_cad registry associates `extensions: ['solidcraft']`).
- **`window.solidcraft` JS API global** — the public browser API surface the one_cad bridge drives.
- **VFS paths** (`/opfs/`, `/downloads/`, plus the OneCAD additions `/odoo/…`) and OPFS/local-storage keys.
- **Crate/binary names and Cargo workspace layout** — untouched; ~876 files mention "solidcraft";
renaming them would make every upstream sync a merge conflict. (`SOLIDCRAFT_COMMIT` /
`SOLIDCRAFT_BUILD_DATE` env names stay; their About text renders as OneCAD.)
- Desktop-only updater internals (registry key, installer file names) keep upstream strings —
unreachable from the web build.
+6 -1
View File
@@ -53,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, plus `crown`), 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, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two boundaries exact, three/four refused pending a Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs 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 | — |
@@ -73,6 +73,11 @@ hierarchies are shared; bounded desktop preparation and occlusion use up to eigh
Curves are approximated; drawing sheets,
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
packages. The first geometry increment defines validated section records in a plane-local
frame; the ledger separates that contract from section producers and the Drawing workspace.
Desktop view DXF export runs in the file-operation worker with five-stage progress and
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
+18
View File
@@ -423,6 +423,24 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
"sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"),
"operation": op(f), "name": name, "body_names": bodies}}));
}
// A boundary surface: a loop of one sketch's curves (`sketch`, `curves`), or a network
// (`u`, `v`), as `surface.boundary` takes them.
Some("boundary_surface") => {
let mut p = json!({"name": name});
for k in ["sketch", "curves", "continuity", "u", "v", "operation"] {
if let Some(v) = f.get(k) {
p[k] = v.clone();
}
}
if let Some(b) = bodies.as_array().and_then(|a| a.first()) {
p["body_name"] = b.clone();
}
out.push(json!({"command": "surface.boundary", "params": p}));
}
Some("thicken") => out.push(json!({"command": "surface.thicken", "params": {
"bodies": f.get("bodies"), "thickness": f.get("thickness"), "symmetric": f.get("symmetric").cloned().unwrap_or(json!(false)),
"operation": op(f), "name": name}})),
Some("remove") => out.push(json!({"command": "solid.remove", "params": {"bodies": f.get("bodies"), "name": name}})),
Some("shell") => {
let faces: Vec<Value> = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect();
out.push(json!({"command": "solid.shell", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}}));
+4
View File
@@ -24,6 +24,10 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
# OneBSpace (OneCAD): std::time::Instant PANICS on wasm32-unknown-unknown
# ("time not implemented on this platform") — every picker deadline in
# services_odoo.rs must use web_time::Instant (performance.now() on wasm).
web-time = "1"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
+21 -18
View File
@@ -3,27 +3,14 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>SolidCraft</title>
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="SolidCraft">
<meta property="og:title" content="SolidCraft: try it in your browser">
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:url" content="https://solidcraft.ai/">
<meta property="og:image" content="https://docs.solidcraft.io/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SolidCraft: try it in your browser">
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta name="twitter:image" content="https://docs.solidcraft.io/og.png">
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<title>OneCAD</title>
<meta name="description" content="OneCAD — parametric CAD of the One B OS workspace, in Rust.">
<!-- OneBSpace (OneCAD): upstream's social-card meta (solidcraft.ai / docs.solidcraft.io)
and PWA manifest + icons are gone — this is our own app inside the Odoo iframe. -->
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
<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" />
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
<!-- 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">
@@ -33,15 +20,31 @@
<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" />
<!-- OneBSpace (OneCAD): the Odoo Documents host bridge. Copied verbatim
(not hashed): the framework's bundleVersion ?v= on the iframe URL is
the cache buster, like every other OneBSpace app bundle. The build
script compiles the erasable-syntax TS (src/odoo/bridge.ts and
src/odoo/index.ts) to odoo/bridge.js / odoo/index.js in dist;
explorer.js is copied as-is (data-trunk copy-dir, same tag shape as
every other entry above). -->
<link data-trunk rel="copy-dir" href="src/odoo" data-target-path="odoo" />
</head>
<body>
<canvas id="solidcraft_canvas"></canvas>
<div id="solidcraft_loading" role="status"><img src="loading-logo.svg" alt=""><span class="sr-only">Loading SolidCraft&hellip;</span></div>
<div id="solidcraft_loading" role="status"><span class="wordmark">OneCAD</span><span class="sr-only">Loading OneCAD&hellip;</span></div>
<script>
// Offline use and updates (sw.js; written into release builds by `cargo xtask web-offline`).
if ("serviceWorker" in navigator) {
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
}
// OneBSpace (OneCAD): boot the Odoo Documents bridge glue only inside a
// frame (the wasm_app_framework shell). Standalone runs never import it.
// odoo/index.js is src/odoo/index.ts compiled by the addon build script.
try {
if (window.parent !== window) {
import("./odoo/index.js?v=20.0.1.0.6").catch((e) => console.warn("OneCAD odoo bridge:", e));
}
} catch (e) { /* cross-origin parent (standalone) — skip */ }
// 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; });
+6
View File
@@ -295,6 +295,12 @@ pub fn start(files: &Rc<Files>) {
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
pub fn flush(files: &Rc<Files>) {
for (path, bytes) in vfs::take_writes() {
// OneBSpace (OneCAD): in the Odoo shell, writes under /odoo/ go to
// the bound Documents record and /downloads/ exports go to the
// "Exports" folder (air-gapped: no device IO through the VFS).
if crate::services_odoo::handle_write(&path, &bytes) {
continue;
}
if let Some(name) = path.strip_prefix(OPFS) {
let name = name.to_string();
{
+21 -10
View File
@@ -1,4 +1,4 @@
//! SolidCraft in the browser.
//! OneCAD in the browser.
//!
//! 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
@@ -12,6 +12,10 @@ mod api;
mod files;
#[cfg(target_arch = "wasm32")]
mod install;
// OneBSpace (OneCAD): Odoo Documents host integration — Services overrides
// (pickers → the JS bridge), VFS /odoo/ + /downloads/ drain, asset mirrors.
#[cfg(target_arch = "wasm32")]
mod services_odoo;
#[cfg(any(target_arch = "wasm32", test))]
mod space_mouse;
@@ -34,14 +38,15 @@ mod web {
/// draws it with reversed depth, whose precision follows the distance from the eye (#1434).
const DEPTH_BITS: u8 = 32;
/// The loading animation (assets/styles/web/loading-logo.svg) settles into the finished mark
/// about 3.2 s after the page starts; this adds a short hold. The app doesn't land before it.
const LOADING_MIN_MS: f64 = 3600.0;
/// OneBSpace (OneCAD): upstream held the loading logo animation on screen for 3.6 s
/// (LOADING_MIN_MS = 3600) — the "splash opener". The wordmark splash is dismissed as
/// soon as the app is ready; the fade itself stays.
const LOADING_MIN_MS: f64 = 0.0;
/// The fade from the loading screen into the app.
const LOADING_FADE_MS: i32 = 300;
/// Take the loading screen away once the animation has played to the finished mark: wait out
/// what is left of `LOADING_MIN_MS`, fade it, then remove it.
/// Take the loading screen away as soon as the app is ready (OneBSpace (OneCAD): no
/// forced minimum hold): fade it, then remove it.
fn dismiss_loading(el: web_sys::Element) {
let Some(window) = web_sys::window() else {
el.remove();
@@ -120,6 +125,10 @@ mod web {
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
}
// OneBSpace (OneCAD): CAD assets brought in from the computer are
// mirrored into the Documents "Assets" folder in the Odoo host
// (before the incoming queue is drained below).
crate::services_odoo::mirror_imports(&self.files);
let mut incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
// A glTF file's buffers picked with it are kept next to it, not opened.
incoming.retain_mut(|(name, bytes, _)| {
@@ -523,6 +532,8 @@ mod web {
install_app: Some(crate::install::service(&cc.egui_ctx)),
..Default::default()
};
// OneBSpace (OneCAD): install odoo services on wasm
let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());
// Thumbnails of saves and start-page cards.
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
let (sender, receiver) = std::sync::mpsc::channel();
@@ -544,11 +555,11 @@ mod web {
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.
// OneBSpace (OneCAD): no start page — the app opens straight
// into the workspace (the house button still shows Home).
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
let rev = app.session.revision;
app.home.show(rev);
}
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("OneCAD browser API: {e:?}"))?;
Ok(Box::new(Shell {
app,
api,
@@ -569,7 +580,7 @@ mod web {
match result {
Ok(()) => dismiss_loading(el),
Err(e) => {
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
el.set_inner_html(&format!("<p>OneCAD failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
}
}
}
+947
View File
@@ -0,0 +1,947 @@
// OneBSpace (OneCAD) — odoo host bridge port
/**
* (C) OneBSpace — https://1bspace.com — Proprietary.
* OneCAD odoo/bridge.ts — the Odoo Documents bridge client + Rust facade.
* -----------------------------------------------------------------------
* Port of the family adapter (one-cut-pro apps/web/src/odoo/bridge.ts,
* vanilla twin: onepixel frontend/src/odoo-host/bridge.ts) onto the
* wasm_app_framework host protocol — authoritative implementation:
* one-os-dev/core-components/wasm_app_framework/static/src/wasm_app_action.js
*
* request : {source: "one-cad", kind: "odoo-documents", id, method, payload}
* posted on window.parent (same-origin Odoo shell iframe)
* reply : {source: "one-cad-host", kind: "odoo-documents", id, ok,
* result | error:{message}}
*
* Host methods and payload shapes (confirmed against the framework):
* ping {} -> {user, folder, app:{root, projects?, assets?, exports?}}
* list {} -> {documents:[{id,name,updatedAt,size}]}
* read {recordId} -> {name, dataB64}
* write {recordId?, name?, dataB64, folderId?} -> {id}
* delete {recordId} -> {deleted}
* usage {} -> {bytesUsed, documentCount}
* folders {} -> {tree:[{id,name,parentId,system}], app:{...app, appsFilesRoot}}
* createFolder {name, parentId?} -> {id}
* listFolder {folderId} -> {folders:[{id,name,system}], files:[{id,name,ext,mimetype,size,updatedAt}]}
* saveExport {name, dataB64, folderId?} -> {id} (folderId omitted -> the app's "exports" subfolder)
* stat {recordId} -> {id, name, mimetype}
*
* Family laws: Documents is the ONLY storage; 60 s calls / 300 s writes;
* one correlation id per call; air-gapped (no external fetch ever).
* The 128 MB per-record cap is enforced HERE, on the raw bytes, before any
* base64 conversion (the host mirrors it on base64 length * 3/4).
*
* RUST EVENT CONTRACT (window CustomEvents; the Rust side is built
* separately and consumes host responses through these):
*
* LISTEN "onecad:host-request" detail {id:number, method:string,
* (Rust -> JS) payload?:object}
* One door for everything. Known host
* methods (ping/list/read/write/delete/
* usage/folders/createFolder/listFolder/
* saveExport/stat) pass straight through
* to the host with their own payload.
* Orchestration methods run dialogs +
* writes in JS and never hit the host:
* "saveAs" {name?, dataB64?, recordId?, bytes?}
* "open" {}
* "export" {name?, dataB64?, bytes?}
* "importAsset" {name?, dataB64?, bytes?}
* Bytes may arrive as dataB64 (JSON) or a
* Uint8Array in payload.bytes; when absent
* the bridge asks the app to serialize
* itself via window.solidcraft (below).
* REQUEST QUEUE: entries are processed
* strictly FIFO, one at a time, and each
* id is answered exactly once.
*
* EMIT "onecad:host-response" detail {id, ok, result?, error?{message}}
* (JS -> Rust) correlate with the request id. Timeouts
* surface as ok:false errors (60 s calls,
* 300 s write/read/saveExport).
*
* EMIT "onecad:host-ready" detail {host:boolean, documentId, name}
* (JS -> Rust) fired once by the boot glue (odoo/
* index.ts) after waitForHost() and any
* ?document= deep-link read. Late-booting
* Rust can read the same state sync at
* window.onecadHost.{ready,host,documentId,name}.
*
* EMIT "onecad:document-opened" detail {documentId, name, dataB64}
* (JS -> Rust) fired after the deep-link record was
* read. The full payload stays available
* for late listeners via
* window.onecadHost.consumePendingOpen()
* (returns the detail once, then null).
*
* JS -> APP window.solidcraft.request("odoo.document.bytes")
* -> Promise<Uint8Array | ArrayBuffer | {dataB64}> — serialize
* the current workspace (used when a save/export carries no
* bytes). request("odoo.document.info") -> Promise<{name?}> —
* suggested document name; failures are ignored.
*
* STANDALONE DEV MODE — when the app is not inside the framework iframe,
* the facade goes inert: waitForHost() resolves immediately (host:false),
* every host call rejects with a clear "standalone" error and host-request
* events are answered ok:false — the app still boots, nothing hangs.
*
* This file is erasable-syntax-only TypeScript with ZERO dependencies: the
* addon build (scripts/build_onecad_addon.py) compiles it to bridge.js next
* to this file (node stripTypeScriptTypes; tsc when available).
*/
const DEFAULT_TIMEOUT_MS = 60_000;
const WRITE_TIMEOUT_MS = 300_000; // large .solidcraft containers over the bridge
const POLL_INTERVAL_MS = 100;
const HOST_WAIT_MS = 20_000; // ping every 100 ms for up to 20 s
/** Family cap (wasm_app_action.js MAX_DOCUMENT_BYTES), mirrored client-side. */
export const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
export const SOURCE_TAG = "one-cad";
export const HOST_SOURCE_TAG = "one-cad-host";
export const BRIDGE_KIND = "odoo-documents";
/** Window CustomEvent names of the Rust facade contract (see header). */
export const EVT_HOST_REQUEST = "onecad:host-request";
export const EVT_HOST_RESPONSE = "onecad:host-response";
export const EVT_HOST_READY = "onecad:host-ready";
export const EVT_DOCUMENT_OPENED = "onecad:document-opened";
/** window.solidcraft request names the bridge calls INTO the app. */
export const APP_SERIALIZE_METHOD = "odoo.document.bytes";
export const APP_INFO_METHOD = "odoo.document.info";
export interface OdooAppTree {
root: number;
projects?: number;
assets?: number;
exports?: number;
appsFilesRoot?: number;
}
export interface PingResult {
user: string;
folder: number;
app: OdooAppTree;
}
export interface FolderRow {
id: number;
name: string;
parentId: number | null;
system?: boolean;
}
export interface FileRow {
id: number;
name: string;
ext?: string;
mimetype?: string;
size: number;
updatedAt?: string;
}
export interface WriteResult {
id: number;
name?: string;
}
export interface ReadResult {
name: string;
dataB64: string;
}
export interface StatResult {
id: number;
name: string;
mimetype: string;
}
export interface UsageResult {
bytesUsed: number;
documentCount: number;
}
export interface FoldersResult {
tree: FolderRow[];
app: OdooAppTree;
}
export interface ListResult {
documents: FileRow[];
}
export interface CallOptions {
timeoutMs?: number;
}
export interface BindEntry {
recordId: number;
name: string;
updatedAt: string;
}
/** The document the app is currently working on (deep link / last save). */
export interface CurrentBinding {
documentId: number | null;
name: string | null;
}
export interface ExplorerChoice {
name?: string;
folderId?: number;
recordId?: number;
recordName?: string;
file?: File;
}
export type ExplorerOpener = (opts: {
mode: "save" | "open" | "asset" | "export";
defaultName?: string;
extensions?: string[];
}) => Promise<ExplorerChoice | null>;
export interface HostRequestDetail {
id: number;
method: string;
payload?: Record<string, unknown> | null;
/** services_odoo.rs names the request object `params`; payload is the glue
* spelling. runRequest accepts either. */
params?: Record<string, unknown> | null;
}
export interface HostResponseDetail {
id: number;
ok: boolean;
result?: unknown;
error?: { message: string } | null;
}
export interface HostReadyDetail {
host: boolean;
documentId: number | null;
name: string | null;
}
export interface DocumentOpenedDetail {
documentId: number;
name: string;
dataB64: string;
}
export interface SaveAsResult {
id: number;
name: string;
folderId: number | null;
}
export interface OpenResult {
id: number;
name: string;
dataB64: string;
}
export interface SavedResult {
id: number;
name: string;
}
/** Host method names accepted on "onecad:host-request" (pass-through). */
const HOST_METHODS = new Set([
"ping", "list", "read", "write", "delete", "usage",
"folders", "createFolder", "listFolder", "saveExport", "stat",
]);
/** Host methods that move bulk bytes — family law says 300 s. */
const BULK_METHODS = new Set(["read", "write", "saveExport"]);
/** Orchestration methods served by this bridge (dialogs + writes). */
const UI_METHODS = new Set(["saveAs", "open", "export", "importAsset"]);
const BIND_KEY_PREFIX = "one-cad:solidcraft:bind:";
const OWNED_EXTENSION = ".solidcraft";
/** Rust services_odoo.rs ASSET_EXTS — pick_program offers these from Documents. */
const RUST_ASSET_EXTS = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
/** Emulated pending paths must sanitize exactly like services_odoo.rs::sanitize. */
function sanitizeName(name: unknown): string {
const cleaned = String(name || "").replace(/[/\\]/g, "_").trim();
return cleaned || "file";
}
/** Pull the Uint8Array payload out of an odoo.write / odoo.import event. */
function bytesOf(payload: Record<string, unknown>): Uint8Array | null {
const raw = payload ? payload.bytes : undefined;
if (!raw) return null;
if (raw instanceof Uint8Array) return raw;
// Cross-realm payloads (tests, odd hosts): isView works across realms.
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(raw)) {
return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
}
if (typeof ArrayBuffer !== "undefined" && raw instanceof ArrayBuffer) return new Uint8Array(raw);
return normalizeBytes(raw);
}
function newToken(): string {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return "t" + window.crypto.randomUUID().replace(/-/g, "");
}
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2);
}
interface PendingTokenBinding {
recordId: number | null;
name: string;
folderId: number | null;
}
interface SolidcraftApi {
request?: (method: string, params?: unknown) => Promise<unknown>;
}
interface PendingEntry {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
interface QueueEntry {
id: number;
method: string;
payload: Record<string, unknown>;
}
/* ------------------------------------------------------------------ */
/* Codec + guard helpers (exported for the explorer and the app glue) */
/* ------------------------------------------------------------------ */
/** Chunked btoa so multi-megabyte CAD containers never blow the arg stack. */
export function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
const slice = bytes.subarray(i, i + chunk);
binary += String.fromCharCode.apply(null, Array.from(slice) as unknown as number[]);
}
return btoa(binary);
}
export function base64ToBytes(dataB64: string): Uint8Array {
const binary = atob(dataB64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/** Family 128 MB cap, enforced on RAW bytes before base64 conversion. */
export function guardCap(bytes: Uint8Array): void {
if (bytes.byteLength > MAX_DOCUMENT_BYTES) {
throw new Error(
"OneCAD odoo bridge: document exceeds the 128 MB bridge limit (" +
bytes.byteLength + " raw bytes)",
);
}
}
function normalizeBytes(value: unknown): Uint8Array | null {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (value && typeof value === "object") {
const holder = value as { dataB64?: unknown };
if (typeof holder.dataB64 === "string") return base64ToBytes(holder.dataB64);
}
return null;
}
function ensureExtension(name: string, extension: string): string {
const trimmed = (name || "").trim() || "Untitled";
const dot = trimmed.lastIndexOf(".");
if (dot > 0) return trimmed;
return trimmed + extension;
}
function stripOwnedExtension(name: string): string {
return (name || "").replace(/\.(solidcraft|solvecraft)$/i, "");
}
function detectInFrame(): boolean {
try {
return window.parent !== window;
} catch {
return false;
}
}
function solidcraftApi(): SolidcraftApi | null {
const holder = window as unknown as { solidcraft?: SolidcraftApi };
const api = holder.solidcraft;
return api && typeof api.request === "function" ? api : null;
}
function messageOf(error: unknown): string {
return error && typeof error === "object" && "message" in error
? String((error as { message?: unknown }).message)
: String(error);
}
/* ------------------------------------------------------------------ */
/* The bridge */
/* ------------------------------------------------------------------ */
class OneCadBridge {
inFrame: boolean;
standalone: boolean = false;
nextId: number = 1;
pending: Map<number, PendingEntry> = new Map();
queue: QueueEntry[] = [];
draining: boolean = false;
opener: ExplorerOpener | null = null;
binding: CurrentBinding = { documentId: null, name: null };
pendingOpen: DocumentOpenedDetail | null = null;
/** odoo.pickSave tokens -> binding (services_odoo.rs writes to
* /odoo/pending/<token>/<name>; the first write creates the record and
* every later Ctrl+S writes back in place). */
pendingTokens = new Map<string, PendingTokenBinding>();
constructor() {
this.inFrame = detectInFrame();
window.addEventListener("message", (event) => this.onMessage(event));
window.addEventListener(EVT_HOST_REQUEST, ((event: Event) => {
const detail = (event as CustomEvent).detail as HostRequestDetail | undefined;
if (!detail || typeof detail.id !== "number" || typeof detail.method !== "string") return;
// services_odoo.rs dispatches the request object as `params`; the
// orchestration glue historically used `payload`. Accept both.
const raw = detail.payload !== undefined ? detail.payload : detail.params;
this.enqueue({
id: detail.id,
method: detail.method,
payload: (raw && typeof raw === "object"
? raw
: {}) as Record<string, unknown>,
});
}) as EventListener);
}
/* ---------------- host plumbing ---------------- */
isHostAvailable(): boolean {
return this.inFrame && !this.standalone;
}
private onMessage(event: MessageEvent): void {
const message = event.data as
| { source?: string; kind?: string; id?: number; ok?: boolean; result?: unknown; error?: { message?: string } | null }
| null;
if (
!message ||
typeof message !== "object" ||
message.source !== HOST_SOURCE_TAG ||
message.kind !== BRIDGE_KIND ||
typeof message.id !== "number"
) {
return;
}
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.ok) pending.resolve(message.result);
else pending.reject(new Error(message.error?.message || "Odoo Documents bridge call failed"));
}
/** One correlation id per call; 60 s default / 300 s for writes. */
call<T>(method: string, payload?: Record<string, unknown>, options?: CallOptions): Promise<T> {
if (!this.inFrame) {
this.standalone = true;
return Promise.reject(
new Error(`OneCAD odoo bridge: standalone (no Odoo host) — "${method}" unavailable`),
);
}
const timeoutMs = (options && options.timeoutMs) || DEFAULT_TIMEOUT_MS;
const id = this.nextId++;
const envelope = {
source: SOURCE_TAG,
kind: BRIDGE_KIND,
id,
method,
payload: payload || {},
};
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`OneCAD odoo bridge: "${method}" timed out after ${timeoutMs} ms`));
}, timeoutMs);
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
// The framework shell is our parent window (same origin).
window.parent.postMessage(envelope, window.location.origin);
});
}
/** Ping every 100 ms until the host answers (mount-order independence). */
async waitForHost(timeoutMs: number = HOST_WAIT_MS): Promise<PingResult> {
if (!this.inFrame) {
this.standalone = true;
return { user: "", folder: 0, app: { root: 0 } };
}
const deadline = Date.now() + timeoutMs;
let lastError: Error | null = null;
while (Date.now() < deadline) {
try {
const pong = await this.ping();
this.standalone = false;
return pong;
} catch (error) {
lastError = error as Error;
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
}
throw new Error(
`OneCAD odoo bridge: host did not answer ping within ${timeoutMs} ms (${messageOf(lastError)})`,
);
}
/* ---------------- raw host calls ---------------- */
ping(): Promise<PingResult> {
return this.call<PingResult>("ping");
}
list(): Promise<ListResult> {
return this.call<ListResult>("list");
}
stat(recordId: number): Promise<StatResult> {
return this.call<StatResult>("stat", { recordId });
}
read(recordId: number): Promise<ReadResult> {
return this.call<ReadResult>("read", { recordId }, { timeoutMs: WRITE_TIMEOUT_MS });
}
write(payload: { recordId?: number; name?: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
return this.call<WriteResult>("write", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
}
delete(recordId: number): Promise<{ deleted: boolean }> {
return this.call<{ deleted: boolean }>("delete", { recordId });
}
usage(): Promise<UsageResult> {
return this.call<UsageResult>("usage");
}
folders(): Promise<FoldersResult> {
return this.call<FoldersResult>("folders");
}
createFolder(payload: { name: string; parentId?: number }): Promise<{ id: number }> {
return this.call<{ id: number }>("createFolder", payload);
}
listFolder(folderId: number): Promise<{ folders: FolderRow[]; files: FileRow[] }> {
return this.call<{ folders: FolderRow[]; files: FileRow[] }>("listFolder", { folderId });
}
saveExport(payload: { name: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
return this.call<WriteResult>("saveExport", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
}
/* ---------------- binding cache (localStorage) ---------------- */
getCurrentBinding(): CurrentBinding {
return this.binding;
}
/** document name -> Odoo record id, keyed by app + document name. */
bindLookup(name: string): BindEntry | null {
try {
const raw = window.localStorage.getItem(BIND_KEY_PREFIX + stripOwnedExtension(name));
if (!raw) return null;
const entry = JSON.parse(raw) as BindEntry;
return entry && typeof entry.recordId === "number" ? entry : null;
} catch {
return null;
}
}
bindSet(name: string, recordId: number): void {
this.binding = { documentId: recordId, name: stripOwnedExtension(name) };
try {
const entry: BindEntry = {
recordId,
name: stripOwnedExtension(name),
updatedAt: new Date().toISOString(),
};
window.localStorage.setItem(BIND_KEY_PREFIX + entry.name, JSON.stringify(entry));
} catch {
/* private mode — memory-only binding still works for this session */
}
}
/* ---------------- document flows ---------------- */
/** Ctrl+S style write-back / create. Guard runs BEFORE base64. */
async saveDocument(bytes: Uint8Array | ArrayBuffer, name: string, recordId?: number): Promise<WriteResult> {
const raw = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
guardCap(raw);
const dataB64 = bytesToBase64(raw);
const clean = ensureExtension(name, OWNED_EXTENSION);
const result = await this.write({ recordId, name: clean, dataB64 });
this.bindSet(result.name || clean, result.id);
return result;
}
/** Deep-link read; also refreshes the current binding. */
async readDocument(recordId: number): Promise<ReadResult> {
const result = await this.read(recordId);
this.bindSet(result.name, recordId);
return result;
}
/**
* ?document=<id> boot deep link. Never throws (boot must proceed);
* on success stores pendingOpen, fires "onecad:document-opened" and
* refreshes the binding so host-ready can carry the metadata.
*/
async openDeepLink(search?: string): Promise<boolean> {
let params: URLSearchParams;
try {
params = new URLSearchParams(search === undefined ? window.location.search : search);
} catch {
return false;
}
const rawId = params.get("document");
if (!rawId) return false;
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) {
console.warn("[onecad-odoo] ignoring malformed deep link ?document=" + rawId);
return false;
}
try {
const opened = await this.readDocument(id);
this.pendingOpen = { documentId: id, name: opened.name, dataB64: opened.dataB64 };
window.dispatchEvent(
new CustomEvent<DocumentOpenedDetail>(EVT_DOCUMENT_OPENED, { detail: this.pendingOpen }),
);
return true;
} catch (error) {
console.warn("[onecad-odoo] deep-link read failed for document " + id + ":", messageOf(error));
return false;
}
}
/** Late-boot consumers fetch the deep-link payload once, then null. */
consumePendingOpen(): DocumentOpenedDetail | null {
const pending = this.pendingOpen;
this.pendingOpen = null;
return pending;
}
setExplorerOpener(opener: ExplorerOpener): void {
this.opener = opener;
}
private async askExplorer(options: {
mode: "save" | "open" | "asset" | "export";
defaultName?: string;
extensions?: string[];
}): Promise<ExplorerChoice | null> {
if (!this.isHostAvailable()) {
throw new Error("OneCAD odoo bridge: standalone (no Odoo host) — explorer unavailable");
}
if (!this.opener) {
throw new Error("OneCAD odoo bridge: no explorer dialog wired (setExplorerOpener)");
}
return this.opener(options);
}
private async appSerialize(): Promise<Uint8Array> {
const api = solidcraftApi();
if (!api || !api.request) {
throw new Error(
"OneCAD odoo bridge: no document bytes given and window.solidcraft is unavailable",
);
}
const out = await api.request(APP_SERIALIZE_METHOD);
const bytes = normalizeBytes(out);
if (!bytes) {
throw new Error("OneCAD odoo bridge: window.solidcraft did not return document bytes");
}
return bytes;
}
private async appInfo(): Promise<{ name?: string }> {
const api = solidcraftApi();
if (!api || !api.request) return {};
try {
const info = (await api.request(APP_INFO_METHOD)) as { name?: unknown } | null;
return info && typeof info.name === "string" ? { name: info.name } : {};
} catch {
return {};
}
}
/** Save-as flow: bytes -> explorer (tree + name) -> write -> bind. */
async saveAs(options?: {
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
recordId?: number;
}): Promise<SaveAsResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
if (!dataB64) {
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
guardCap(raw);
dataB64 = bytesToBase64(raw);
}
const info = await this.appInfo();
const suggested = opts.name || info.name || "untitled";
const choice = await this.askExplorer({
mode: "save",
defaultName: ensureExtension(suggested, OWNED_EXTENSION),
});
if (!choice || !choice.name) return null; // cancelled
const name = ensureExtension(choice.name, OWNED_EXTENSION);
const result = await this.write({
recordId: opts.recordId,
name,
dataB64,
folderId: choice.folderId,
});
this.bindSet(result.name || name, result.id);
return { id: result.id, name: result.name || name, folderId: choice.folderId || null };
}
/** Open flow: explorer (.solidcraft filter) -> read 300 s. */
async openFromDocuments(): Promise<OpenResult | null> {
const choice = await this.askExplorer({ mode: "open", extensions: ["solidcraft", "solvecraft"] });
if (!choice || typeof choice.recordId !== "number") return null; // cancelled
const opened = await this.readDocument(choice.recordId);
return { id: choice.recordId, name: opened.name, dataB64: opened.dataB64 };
}
/** Export flow: bytes -> (optional name dialog) -> saveExport -> Exports. */
async exportTo(options?: {
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
}): Promise<SavedResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
if (!dataB64) {
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
guardCap(raw);
dataB64 = bytesToBase64(raw);
}
let name = (opts.name || "").trim();
let folderId: number | undefined;
if (!name) {
const info = await this.appInfo();
const choice = await this.askExplorer({
mode: "export",
defaultName: (info.name || "export") + ".step",
});
if (!choice || !choice.name) return null; // cancelled
name = choice.name;
folderId = choice.folderId;
}
const result = await this.saveExport({ name, dataB64, folderId });
return { id: result.id, name: result.name || name };
}
/** Import flow: file picker (or given bytes) -> write into Assets. */
async importAsset(options?: {
file?: File;
bytes?: Uint8Array | ArrayBuffer;
dataB64?: string;
name?: string;
}): Promise<SavedResult | null> {
const opts = options || {};
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
let name = (opts.name || "").trim();
let folderId: number | undefined;
if (!dataB64 && !opts.file && !opts.bytes) {
const choice = await this.askExplorer({ mode: "asset" });
if (!choice || !(choice.file instanceof File)) return null; // cancelled
folderId = choice.folderId;
const raw = new Uint8Array(await choice.file.arrayBuffer());
guardCap(raw);
dataB64 = bytesToBase64(raw);
name = name || choice.file.name;
} else if (!dataB64) {
const raw = opts.bytes
? opts.bytes instanceof Uint8Array
? opts.bytes
: new Uint8Array(opts.bytes)
: new Uint8Array(await (opts.file as File).arrayBuffer());
guardCap(raw);
dataB64 = bytesToBase64(raw);
name = name || (opts.file ? opts.file.name : "asset");
}
if (!folderId) {
const app = (await this.ping()).app;
folderId = app.assets || app.root;
}
const result = await this.write({ name: name || "asset", dataB64, folderId });
return { id: result.id, name: result.name || name || "asset" };
}
/* ---------------- Rust event queue ---------------- */
private enqueue(entry: QueueEntry): void {
this.queue.push(entry);
void this.drainQueue();
}
/** Strictly FIFO, one request at a time; every id answered exactly once. */
private async drainQueue(): Promise<void> {
if (this.draining) return;
this.draining = true;
while (this.queue.length > 0) {
const entry = this.queue.shift() as QueueEntry;
try {
const result = await this.runRequest(entry.method, entry.payload);
this.respond(entry.id, true, result, null);
} catch (error) {
this.respond(entry.id, false, null, { message: messageOf(error) });
}
}
this.draining = false;
}
private respond(id: number, ok: boolean, result: unknown, error: { message: string } | null): void {
const detail: HostResponseDetail = { id, ok, result, error };
window.dispatchEvent(new CustomEvent<HostResponseDetail>(EVT_HOST_RESPONSE, { detail }));
}
private async runRequest(method: string, payload: Record<string, unknown>): Promise<unknown> {
if (method === "odoo.pickOpen" || method === "odoo.pickProgram") return this.pickForRust(method, payload);
if (method === "odoo.pickSave") return this.pickSaveForRust(payload);
if (method === "odoo.write") return this.rustWrite(payload);
if (method === "odoo.saveExport") return this.rustSaveExport(payload);
if (method === "odoo.import") return this.rustImport(payload);
if (method === "saveAs") {
return this.saveAs({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
recordId: typeof payload.recordId === "number" ? payload.recordId : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
});
}
if (method === "open") return this.openFromDocuments();
if (method === "export") {
return this.exportTo({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
});
}
if (method === "importAsset") {
return this.importAsset({
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
name: typeof payload.name === "string" ? payload.name : undefined,
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
file: payload.file instanceof File ? payload.file : undefined,
});
}
if (HOST_METHODS.has(method)) {
const timeoutMs = BULK_METHODS.has(method) ? WRITE_TIMEOUT_MS : undefined;
return this.call<unknown>(method, payload, { timeoutMs });
}
throw new Error("OneCAD odoo bridge: unknown host-request method \"" + method + "\"");
}
/* ------- services_odoo.rs wire protocol (CustomEvent round trip) ------- */
/** odoo.pickOpen / odoo.pickProgram -> {recordId, name, bytes} | null. */
private async pickForRust(method: string, payload: Record<string, unknown>): Promise<unknown> {
const requested = Array.isArray(payload.extensions) && payload.extensions.length
? (payload.extensions as string[])
: null;
const extensions = requested || (method === "odoo.pickProgram" ? RUST_ASSET_EXTS : ["solidcraft", "solvecraft"]);
const choice = await this.askExplorer({ mode: "open", extensions });
if (!choice || typeof choice.recordId !== "number") return null;
const opened = await this.readDocument(choice.recordId);
return {
recordId: choice.recordId,
name: opened.name || choice.recordName || "document",
bytes: base64ToBytes(opened.dataB64),
};
}
/** odoo.pickSave -> {token, name}; the token binds the later write-back. */
private async pickSaveForRust(payload: Record<string, unknown>): Promise<unknown> {
const design = payload.design !== false;
const suggested = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : "untitled";
const choice = await this.askExplorer({
mode: "save",
defaultName: design ? ensureExtension(suggested, OWNED_EXTENSION) : suggested,
});
if (!choice || !choice.name) return null;
const token = newToken();
const name = sanitizeName(choice.name);
this.pendingTokens.set(token, { recordId: null, name, folderId: choice.folderId || null });
return { token, name };
}
/** odoo.write: /odoo/doc/<rid>/<name> in place, /odoo/pending/<token>/<name> first save. */
private async rustWrite(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.write: no bytes given");
guardCap(bytes);
const dataB64 = bytesToBase64(bytes);
const path = typeof payload.path === "string" ? payload.path : "";
if (path.startsWith("/odoo/pending/")) {
const rest = path.slice("/odoo/pending/".length);
const slash = rest.indexOf("/");
const token = slash > 0 ? rest.slice(0, slash) : "";
const pathName = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
const bind = token ? this.pendingTokens.get(token) : undefined;
const name = (bind ? bind.name : "") || pathName;
let recordId = bind ? bind.recordId : null;
if (!recordId) {
const remembered = this.bindLookup(name);
recordId = remembered ? remembered.recordId : null;
}
const result = await this.write({ recordId, name, dataB64, folderId: bind ? bind.folderId : undefined });
if (bind) bind.recordId = result.id;
else if (token) this.pendingTokens.set(token, { recordId: result.id, name, folderId: null });
this.bindSet(result.name || name, result.id);
return { id: result.id, name: result.name || name };
}
if (path.startsWith("/odoo/doc/")) {
const rest = path.slice("/odoo/doc/".length);
const slash = rest.indexOf("/");
const recordId = slash > 0 ? Number(rest.slice(0, slash)) : 0;
const name = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
if (!Number.isInteger(recordId) || recordId <= 0) {
throw new Error("odoo.write: bad record path \"" + path + "\"");
}
const result = await this.write({ recordId, name, dataB64 });
this.bindSet(result.name || name, recordId);
return { id: result.id, name: result.name || name };
}
throw new Error("odoo.write: unsupported path \"" + path + "\"");
}
/** odoo.saveExport: {name, bytes} -> Documents "Exports" folder. */
private async rustSaveExport(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.saveExport: no bytes given");
guardCap(bytes);
const result = await this.saveExport({ name: sanitizeName(payload.name) || "export", dataB64: bytesToBase64(bytes) });
return { id: result.id, name: result.name };
}
/** odoo.import: mirror a computer import into the Documents "Assets" folder. */
private async rustImport(payload: Record<string, unknown>): Promise<unknown> {
const bytes = bytesOf(payload);
if (!bytes) throw new Error("odoo.import: no bytes given");
guardCap(bytes);
const app = (await this.ping()).app;
const result = await this.write({ name: payload.name || "asset", dataB64: bytesToBase64(bytes), folderId: app.assets || app.root });
return { id: result.id, name: result.name };
}
}
/** The process-wide singleton (one bridge per app window). */
export const bridge = new OneCadBridge();
/** Family export: the app glue and dialogs always share this instance. */
export function exportForHost(): OneCadBridge {
return bridge;
}
+719
View File
@@ -0,0 +1,719 @@
// OneBSpace (OneCAD) — odoo host bridge port
/**
* (C) OneBSpace — https://1bspace.com — Proprietary.
* OneCAD odoo/explorer.js — the unified Documents explorer dialogs.
* ----------------------------------------------------------------------
* Vanilla-JS (ES module, zero deps) port of the family gold-standard
* explorer (one-photo src/odoo-host/explorer.js — same layout, same
* interactions, same last-folder memory), restyled with an injected dark
* CAD palette (no CSS imports — one <style> tag) so it reads native over
* the egui canvas. Four dialogs, one explorer:
*
* save — "Save to Documents": folder tree + name input (.solidcraft chip)
* open — "Open from Documents": folder tree + files filtered to .solidcraft
* asset — "Import asset": file picker -> Assets folder (+ retarget)
* export — "Export": name input -> Exports folder
*
* left — My Drive folder tree (recursive, expandable, usage footer)
* right — breadcrumb + folder/file rows (icon, name, size)
* footer — Cancel left, primary action right, Esc / backdrop cancels
*
* Everything runs through the bridge (odoo/bridge.js — compiled from
* bridge.ts): listFolder / createFolder / folders / usage. No framework.
*
* @returns per mode (resolve value, null = cancelled):
* save -> {name, folderId}
* open -> {recordId, recordName}
* asset -> {file, folderId, name}
* export -> {name, folderId}
*/
import { bridge } from "./bridge.js?v=20.0.1.0.6";
const STYLE_ID = "oca-explorer-style";
const MODES = {
save: {
heading: "Save to Documents",
description: "Choose a folder in your Odoo drive for this .solidcraft document.",
action: "Save",
prefer: "root",
lastKey: "one-cad:documents:last-folder:save",
},
open: {
heading: "Open from Documents",
description: "Browse your Odoo Documents drive and pick a .solidcraft document.",
action: "Open",
prefer: "root",
lastKey: "one-cad:documents:last-folder:open",
extensions: new Set(["solidcraft", "solvecraft"]),
},
asset: {
heading: "Import asset",
description: "Pick a CAD asset from your computer to copy into the Assets folder.",
action: "Import",
prefer: "assets",
lastKey: "one-cad:documents:last-folder:asset",
extensions: new Set([
"step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf",
"dxf", "svg", "gltf", "glb", "ply", "urdf",
]),
},
export: {
heading: "Export",
description: "Name the export — it lands in your Documents Exports folder.",
action: "Export",
prefer: "exports",
lastKey: "one-cad:documents:last-folder:export",
},
};
/* ------------------------------------------------------------------ */
/* Small SVG icon set (lucide-style, stroke = currentColor) */
/* ------------------------------------------------------------------ */
function svg(paths, size) {
return (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" ' +
'style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex:none">' +
paths + "</svg>"
);
}
const ICONS = {
folder: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'),
lock: svg('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', 11),
chevronRight: svg('<path d="m9 18 6-6-6-6"/>', 12),
hardDrive: svg('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>'),
file: svg('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>'),
cube: svg('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
save: svg('<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 1-1 1H7"/>'),
folderPlus: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="M9 13h6"/>'),
upload: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>'),
open: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="m2 21 5.1-5.1a2 2 0 0 1 2.8 0l1.1 1.1a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 1 2.8 0L22 14"/>'),
download: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>'),
};
/* ------------------------------------------------------------------ */
/* Injected dark CAD styles (the only CSS this module ever adds) */
/* ------------------------------------------------------------------ */
const STYLE_CSS = [
":root{--oca-bg:#1b1e24;--oca-panel:#23272f;--oca-panel2:#2a2f39;--oca-hairline:#333945;",
"--oca-text:#e8eaed;--oca-dim:#9aa3ad;--oca-accent:#4f8cff;--oca-accent-press:#3f76e0;",
"--oca-danger:#e5534b;--oca-ok:#3fb26f}",
".oca-explorer-root{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;",
"font:13px/1.45 'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--oca-text)}",
".oca-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
".oca-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
"background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:10px;",
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
".oca-header{padding:14px 18px 10px;border-bottom:1px solid var(--oca-hairline);background:var(--oca-panel)}",
".oca-title{font-size:15px;font-weight:600}",
".oca-desc{margin-top:2px;color:var(--oca-dim);font-size:12px}",
".oca-desc-error{color:var(--oca-danger)}",
".oca-body{flex:1;display:flex;min-height:0}",
".oca-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--oca-hairline);background:var(--oca-panel)}",
".oca-tree-box{flex:1;overflow:auto;padding:8px 6px}",
".oca-tree-label{margin:2px 8px 6px;color:var(--oca-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
".oca-tree-error{padding:8px}",
".oca-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
".oca-tree-row:hover{background:var(--oca-panel2)}",
".oca-tree-row.oca-active{background:var(--oca-panel2);color:#fff}",
".oca-tree-toggle{width:14px;height:14px;flex:none;color:var(--oca-dim);display:inline-flex;transition:transform .12s}",
".oca-tree-toggle.oca-open{transform:rotate(90deg)}",
".oca-tree-icon{display:inline-flex;color:var(--oca-accent)}",
".oca-tree-name{overflow:hidden;text-overflow:ellipsis}",
".oca-usage{padding:8px 14px;border-top:1px solid var(--oca-hairline);color:var(--oca-dim);font-size:11px}",
".oca-main{flex:1;display:flex;flex-direction:column;min-width:0}",
".oca-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--oca-hairline);overflow:hidden}",
".oca-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--oca-dim);",
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
".oca-crumb:hover:not(:disabled){background:var(--oca-panel2);color:var(--oca-text)}",
".oca-crumb:disabled{opacity:.45;cursor:default}",
".oca-crumb-current{color:var(--oca-text)}",
".oca-crumb-sep{display:inline-flex;color:var(--oca-dim)}",
".oca-files{flex:1;overflow:auto;padding:6px 8px}",
".oca-file-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:0;",
"color:inherit;font:inherit;padding:7px 9px;border-radius:6px;cursor:pointer}",
".oca-file-row:hover{background:var(--oca-panel2)}",
".oca-file-row.oca-selected{background:rgba(79,140,255,.18);outline:1px solid var(--oca-accent)}",
".oca-file-icon{display:inline-flex;color:var(--oca-dim)}",
".oca-file-icon.oca-icon-owned{color:var(--oca-accent)}",
".oca-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".oca-file-size{color:var(--oca-dim);font-size:11px;flex:none}",
".oca-empty{color:var(--oca-dim);padding:18px 10px;text-align:center}",
".oca-savebar{border-top:1px solid var(--oca-hairline);padding:10px 14px;background:var(--oca-panel)}",
".oca-name-row{display:flex;align-items:flex-end;gap:10px}",
".oca-name-wrap{flex:1;min-width:0}",
".oca-name-label{display:block;color:var(--oca-dim);font-size:11px;margin-bottom:4px}",
".oca-name-field{display:flex;align-items:center;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;padding:0 8px}",
".oca-name-field:focus-within{border-color:var(--oca-accent)}",
".oca-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--oca-text);font:inherit;padding:7px 0}",
".oca-suffix{color:var(--oca-dim);font-size:12px;user-select:none}",
".oca-create-row{display:flex;gap:8px;margin-top:8px}",
".oca-newfolder-input{flex:1;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;",
"color:var(--oca-text);font:inherit;padding:6px 9px;outline:0}",
".oca-newfolder-input:focus{border-color:var(--oca-accent)}",
".oca-picker{display:flex;flex-direction:column;gap:8px}",
".oca-pick-btn{display:inline-flex;align-items:center;gap:8px}",
".oca-picked{color:var(--oca-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".oca-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
"border-top:1px solid var(--oca-hairline);background:var(--oca-panel)}",
".oca-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
"border:1px solid var(--oca-hairline);background:var(--oca-panel2);color:var(--oca-text);cursor:pointer}",
".oca-btn:hover:not(:disabled){border-color:var(--oca-dim)}",
".oca-btn:disabled{opacity:.45;cursor:default}",
".oca-btn-accent{background:var(--oca-accent);border-color:var(--oca-accent);color:#fff}",
".oca-btn-accent:hover:not(:disabled){background:var(--oca-accent-press)}",
".oca-btn-ghost{background:none;border-color:transparent;color:var(--oca-dim)}",
".oca-btn-ghost:hover:not(:disabled){color:var(--oca-text)}",
".oca-btn-icon{display:inline-flex}",
].join("\n");
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = STYLE_CSS;
document.head.appendChild(style);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function h(tag, attrs) {
const el = document.createElement(tag);
if (attrs) {
for (const key in attrs) {
if (!Object.prototype.hasOwnProperty.call(attrs, key)) continue;
const value = attrs[key];
if (key === "class") el.className = value;
else if (key === "html") el.innerHTML = value;
else if (key === "text") el.textContent = value;
else if (key.slice(0, 2) === "on" && typeof value === "function")
el.addEventListener(key.slice(2), value);
else if (value !== null && value !== undefined) el.setAttribute(key, value);
}
}
for (let i = 2; i < arguments.length; i++) {
const child = arguments[i];
if (child === null || child === undefined) continue;
el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
}
return el;
}
function formatSize(size) {
size = Number(size) || 0;
if (size < 1024) return size + " B";
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KB";
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MB";
return (size / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}
function extensionOf(name) {
const dot = name.lastIndexOf(".");
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
}
function fileIconFor(file, extensions) {
const ext = extensionOf(file.name || "");
if (extensions && extensions.has(ext)) {
return '<span class="oca-file-icon oca-icon-owned">' + ICONS.cube + "</span>";
}
return '<span class="oca-file-icon">' + ICONS.file + "</span>";
}
function rememberLastFolder(folderId, key) {
try {
window.localStorage.setItem(key, String(folderId));
} catch (e) {
/* private mode — memory only */
}
}
function restoreLastFolder(key) {
try {
const id = Number(window.localStorage.getItem(key));
return Number.isInteger(id) && id > 0 ? id : null;
} catch (e) {
return null;
}
}
/* ------------------------------------------------------------------ */
/* The explorer */
/* ------------------------------------------------------------------ */
/**
* Open one Documents explorer dialog.
*
* @param {object} opts
* mode "save" | "open" | "asset" | "export"
* defaultName save/export mode: suggested file name ("part.solidcraft")
* extensions open/asset mode: restrict file rows to these extensions
* @returns {Promise<{name?, folderId?, recordId?, recordName?, file?} | null>}
* null = cancelled (Esc, backdrop, Cancel button).
*/
export function openDocumentsExplorer(opts) {
opts = opts || {};
const mode = MODES[opts.mode] ? opts.mode : "save";
const config = MODES[mode];
const extensions = opts.extensions && opts.extensions.length
? new Set(opts.extensions.map((ext) => String(ext).toLowerCase().replace(/^\./, "")))
: config.extensions || null;
/* Standalone dev mode: no host, no dialog — the caller treats null as
* "nothing chosen", so the app keeps running untouched. */
if (typeof bridge.isHostAvailable === "function" && !bridge.isHostAvailable()) {
console.info("[onecad-odoo] explorer: no Odoo host (standalone) — dialog unavailable");
return Promise.resolve(null);
}
ensureStyle();
/* Save/export name split: stem in the field, extension as the chip. */
const wantsName = mode === "save" || mode === "export";
const defaultExtension = mode === "save" ? ".solidcraft" : "";
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
? opts.defaultName.trim()
: mode === "save"
? "untitled.solidcraft"
: "export";
let stem = defaultName;
let suffix = "";
const dotIdx = defaultName.lastIndexOf(".");
if (dotIdx > 0) {
stem = defaultName.slice(0, dotIdx);
suffix = defaultName.slice(dotIdx);
}
return new Promise((resolve) => {
let settled = false;
const settle = (result) => {
if (settled) return;
settled = true;
document.removeEventListener("keydown", onDocKeyDown, true);
if (root && root.parentNode) root.parentNode.removeChild(root);
resolve(result);
};
/* ---------------- state ---------------- */
let tree = null;
let treeError = null;
let appTree = {};
let appRootId = null;
let currentFolderId = null;
let folderFolders = [];
let folderFiles = [];
let loadingFiles = false;
let selectedFileId = null;
let pickedFile = null;
let createBusy = false;
let usage = null;
const expandedSet = new Set();
/* ---------------- chrome ---------------- */
const backdrop = h("div", { class: "oca-backdrop" });
const dialog = h("div", { class: "oca-dialog", role: "dialog", "aria-modal": "true" });
const root = h("div", { class: "oca-explorer-root" }, backdrop, dialog);
const descEl = h("div", { class: "oca-desc" }, config.description);
const header = h("div", { class: "oca-header" },
h("div", { class: "oca-title" }, config.heading), descEl);
const treeLabel = h("p", { class: "oca-tree-label" }, "Loading drive…");
const treeBox = h("div", { class: "oca-tree-box" }, treeLabel);
const usageEl = h("div", { class: "oca-usage", style: "display:none" });
const aside = h("div", { class: "oca-aside" }, treeBox, usageEl);
const crumbs = h("div", { class: "oca-crumbs" });
const fileList = h("div", { class: "oca-files" });
const main = h("div", { class: "oca-main" }, crumbs, fileList);
const body = h("div", { class: "oca-body" }, aside, main);
/* name row (save + export) */
const nameInput = h("input", {
class: "oca-name-input",
id: "onecad-explorer-name",
type: "text",
value: stem,
placeholder: mode === "save" ? "Untitled" : "export",
spellcheck: "false",
});
const suffixChip = h("span", { class: "oca-suffix" }, suffix);
const nameField = h("div", { class: "oca-name-field" }, nameInput, suffixChip);
const nameRow = h("div", { class: "oca-name-row" },
h("div", { class: "oca-name-wrap" },
h("label", { class: "oca-name-label", for: "onecad-explorer-name" }, "File name"),
nameField));
/* new-folder row (save + export; asset keeps it too — retargeting) */
const newNameInput = h("input", {
class: "oca-newfolder-input",
type: "text",
placeholder: "Folder name…",
spellcheck: "false",
});
const createBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" }, "Create");
const cancelCreateBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
const createRow = h("div", { class: "oca-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
const newFolderBtn = h("button", { class: "oca-btn", type: "button" },
h("span", { class: "oca-btn-icon", html: ICONS.folderPlus }), "New folder");
/* asset file picker */
const fileInput = h("input", {
type: "file",
style: "display:none",
...(extensions ? { accept: Array.from(extensions).map((ext) => "." + ext).join(",") } : {}),
});
const pickedLabel = h("div", { class: "oca-picked", text: "No file chosen yet." });
const pickBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
h("span", { class: "oca-btn-icon", html: ICONS.upload }), "Choose file…");
const picker = h("div", { class: "oca-picker" }, pickBtn, pickedLabel, fileInput);
const savebar = h("div", { class: "oca-savebar" });
if (wantsName) savebar.appendChild(nameRow);
if (mode === "asset") savebar.appendChild(picker);
savebar.appendChild(newFolderBtn);
savebar.appendChild(createRow);
/* footer */
const cancelBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
const actionIcon = mode === "save" ? ICONS.save : mode === "open" ? ICONS.open : mode === "asset" ? ICONS.upload : ICONS.download;
const actionBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
h("span", { class: "oca-btn-icon", html: actionIcon }), config.action);
const footer = h("div", { class: "oca-footer" }, cancelBtn, actionBtn);
dialog.appendChild(header);
dialog.appendChild(body);
if (mode !== "open") dialog.appendChild(savebar);
dialog.appendChild(footer);
const redraw = () => {
if (mode === "save") {
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
} else if (mode === "open") {
actionBtn.disabled = selectedFileId === null;
} else if (mode === "asset") {
actionBtn.disabled = !pickedFile || currentFolderId === null;
} else {
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
}
};
/* ---------------- tree rendering ---------------- */
function renderTree() {
treeBox.innerHTML = "";
if (tree === null && !treeError) {
treeBox.appendChild(h("p", { class: "oca-tree-label" }, "Loading drive…"));
return;
}
if (treeError) {
const retry = h("button", { class: "oca-btn", type: "button" }, "Retry");
retry.addEventListener("click", () => boot());
treeBox.appendChild(h("div", { class: "oca-tree-error" },
h("p", { class: "oca-tree-label" }, treeError), retry));
return;
}
treeBox.appendChild(h("p", { class: "oca-tree-label" }, "My Drive"));
const childrenOf = new Map();
for (const folder of tree) {
const key = folder.parentId === null ? "root" : String(folder.parentId);
if (!childrenOf.has(key)) childrenOf.set(key, []);
childrenOf.get(key).push(folder);
}
const renderNodes = (parentId, depth) => {
const key = parentId === null ? "root" : String(parentId);
const children = childrenOf.get(key) || [];
for (const folder of children) {
const hasChildren = (childrenOf.get(String(folder.id)) || []).length > 0;
const isOpen = expandedSet.has(folder.id);
const row = h("div", {
class: "oca-tree-row" + (folder.id === currentFolderId ? " oca-active" : ""),
style: "padding-left:" + (8 + depth * 14) + "px",
});
const toggle = h("span", { class: "oca-tree-toggle" });
if (hasChildren) {
toggle.innerHTML = ICONS.chevronRight;
toggle.classList.add("oca-has-children");
if (isOpen) toggle.classList.add("oca-open");
}
toggle.addEventListener("click", (ev) => {
ev.stopPropagation();
if (expandedSet.has(folder.id)) expandedSet.delete(folder.id);
else expandedSet.add(folder.id);
renderTree();
});
row.appendChild(toggle);
row.appendChild(h("span", { class: "oca-tree-icon", html: ICONS.folder }));
row.appendChild(h("span", { class: "oca-tree-name" }, folder.name));
row.addEventListener("click", () => enterFolder(folder.id));
treeBox.appendChild(row);
if (hasChildren && isOpen) renderNodes(folder.id, depth + 1);
}
};
renderNodes(null, 0);
usageEl.style.display = usage ? "" : "none";
if (usage) {
usageEl.textContent = usage.documentCount + " file" + (usage.documentCount === 1 ? "" : "s") +
" · " + formatSize(usage.bytesUsed) + " used";
}
}
/* ---------------- breadcrumb ---------------- */
function renderCrumbs() {
crumbs.innerHTML = "";
const driveBtn = h("button", { class: "oca-crumb oca-crumb-root", type: "button" });
driveBtn.appendChild(h("span", { class: "oca-btn-icon", html: ICONS.hardDrive }));
driveBtn.appendChild(document.createTextNode("My Drive"));
driveBtn.disabled = appRootId === null;
driveBtn.addEventListener("click", () => appRootId !== null && enterFolder(appRootId));
crumbs.appendChild(driveBtn);
const path = [];
let cursor = currentFolderId;
const byId = new Map(tree ? tree.map((row) => [row.id, row]) : []);
while (cursor !== null && byId.has(cursor)) {
path.unshift(byId.get(cursor));
cursor = byId.get(cursor).parentId;
}
for (const folder of path) {
crumbs.appendChild(h("span", { class: "oca-crumb-sep", html: ICONS.chevronRight }));
const crumb = h("button", {
class: "oca-crumb" + (folder.id === currentFolderId ? " oca-crumb-current" : ""),
type: "button",
}, folder.name);
crumb.addEventListener("click", () => enterFolder(folder.id));
crumbs.appendChild(crumb);
}
}
/* ---------------- files ---------------- */
function renderFiles() {
fileList.innerHTML = "";
if (loadingFiles) {
fileList.appendChild(h("p", { class: "oca-empty" }, "Loading…"));
return;
}
/* Desktop file-manager behavior: subfolders first (click = enter),
* then files — open mode filtered to the requested extensions. */
for (const folder of folderFolders) {
const row = h("button", { class: "oca-file-row", type: "button" });
row.insertAdjacentHTML("beforeend", '<span class="oca-file-icon oca-icon-owned">' + ICONS.folder + "</span>");
row.appendChild(h("span", { class: "oca-file-name" }, folder.name));
if (folder.system) {
row.insertAdjacentHTML("beforeend",
'<span class="oca-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
}
row.appendChild(h("span", { class: "oca-file-size" }, "Folder"));
row.addEventListener("click", () => enterFolder(folder.id));
fileList.appendChild(row);
}
const visibleFiles = extensions
? folderFiles.filter((file) => extensions.has(extensionOf(file.name || "")))
: folderFiles;
if (folderFolders.length === 0 && visibleFiles.length === 0) {
fileList.appendChild(h("p", { class: "oca-empty" },
mode === "save" || mode === "export" ? "This folder is empty." : "No compatible files in this folder."));
return;
}
if (visibleFiles.length === 0) {
fileList.appendChild(h("p", { class: "oca-empty" }, "No compatible files here yet."));
return;
}
for (const file of visibleFiles) {
const row = h("button", {
class: "oca-file-row" + (selectedFileId === file.id ? " oca-selected" : ""),
type: "button",
});
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
row.appendChild(h("span", { class: "oca-file-name" }, file.name));
row.appendChild(h("span", { class: "oca-file-size" }, formatSize(file.size || 0)));
row.addEventListener("click", () => {
selectedFileId = file.id;
renderFiles();
redraw();
});
row.addEventListener("dblclick", () => {
if (mode === "open") settle({ recordId: file.id, recordName: file.name });
});
fileList.appendChild(row);
}
}
/* ---------------- actions ---------------- */
function enterFolder(folderId) {
currentFolderId = folderId;
selectedFileId = null;
rememberLastFolder(folderId, config.lastKey);
loadFiles(folderId);
renderTree();
renderCrumbs();
redraw();
}
function loadFiles(folderId) {
loadingFiles = true;
renderFiles();
bridge
.listFolder(folderId)
.then((listing) => {
folderFiles = listing.files || [];
folderFolders = listing.folders || [];
loadingFiles = false;
renderFiles();
})
.catch((error) => {
folderFiles = [];
folderFolders = [];
loadingFiles = false;
renderFiles();
flashError("Could not list this folder — " + (error && error.message ? error.message : "unknown error"));
});
}
function onCreateFolder() {
const trimmed = newNameInput.value.trim();
if (!trimmed || currentFolderId === null || createBusy) return;
createBusy = true;
createBtn.disabled = true;
bridge
.createFolder({ name: trimmed, parentId: currentFolderId })
.then((created) => {
tree = (tree || []).concat([{ id: created.id, name: trimmed, parentId: currentFolderId }]);
expandedSet.add(currentFolderId);
expandedSet.add(created.id);
newNameInput.value = "";
createRow.style.display = "none";
newFolderBtn.style.display = "";
enterFolder(created.id);
})
.catch((error) => {
flashError("Could not create folder — " + (error && error.message ? error.message : "unknown error"));
})
.finally(() => {
createBusy = false;
createBtn.disabled = false;
});
}
function confirmAction() {
if (mode === "save") {
const name = nameInput.value.trim();
if (!name || currentFolderId === null) return;
settle({ name, folderId: currentFolderId });
} else if (mode === "open") {
const file = folderFiles.find((candidate) => candidate.id === selectedFileId);
if (file) settle({ recordId: file.id, recordName: file.name });
} else if (mode === "asset") {
if (pickedFile && currentFolderId !== null) {
settle({ file: pickedFile, folderId: currentFolderId, name: pickedFile.name });
}
} else {
const name = nameInput.value.trim();
if (!name || currentFolderId === null) return;
settle({ name, folderId: currentFolderId });
}
}
pickBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
pickedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
pickedLabel.textContent = pickedFile
? pickedFile.name + " · " + formatSize(pickedFile.size)
: "No file chosen yet.";
redraw();
});
newFolderBtn.addEventListener("click", () => {
newFolderBtn.style.display = "none";
createRow.style.display = "";
newNameInput.focus();
});
cancelCreateBtn.addEventListener("click", () => {
newNameInput.value = "";
createRow.style.display = "none";
newFolderBtn.style.display = "";
});
createBtn.addEventListener("click", onCreateFolder);
newNameInput.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") onCreateFolder();
});
nameInput.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") confirmAction();
});
nameInput.addEventListener("input", redraw);
cancelBtn.addEventListener("click", () => settle(null));
actionBtn.addEventListener("click", confirmAction);
backdrop.addEventListener("mousedown", () => settle(null));
dialog.addEventListener("mousedown", (ev) => ev.stopPropagation());
function onDocKeyDown(ev) {
if (ev.key !== "Escape") return;
ev.stopPropagation();
settle(null);
}
document.addEventListener("keydown", onDocKeyDown, true);
let errorTimer = null;
function flashError(message) {
descEl.textContent = message;
descEl.classList.add("oca-desc-error");
if (errorTimer) clearTimeout(errorTimer);
errorTimer = setTimeout(() => {
descEl.textContent = config.description;
descEl.classList.remove("oca-desc-error");
}, 5000);
}
/* ---------------- boot ---------------- */
function boot() {
tree = null;
treeError = null;
renderTree();
void (async () => {
try {
const folders = await bridge.folders();
tree = folders.tree || [];
appTree = folders.app || {};
appRootId = typeof appTree.root === "number" ? appTree.root : null;
const preferred = typeof appTree[config.prefer] === "number"
? appTree[config.prefer]
: appTree.root;
const restored = restoreLastFolder(config.lastKey);
const known = new Set(tree.map((row) => row.id));
const target = restored && known.has(restored) ? restored : preferred;
currentFolderId = target;
const byId = new Map(tree.map((row) => [row.id, row]));
let cursor = target;
while (cursor !== null) {
expandedSet.add(cursor);
cursor = byId.has(cursor) ? byId.get(cursor).parentId : null;
}
renderTree();
renderCrumbs();
loadFiles(target);
bridge
.usage()
.then((info) => {
usage = info;
renderTree();
})
.catch(() => undefined);
} catch (error) {
treeError = error && error.message ? error.message : "Could not load the drive";
renderTree();
}
redraw();
if (wantsName) {
window.setTimeout(() => {
nameInput.focus();
nameInput.select();
}, 50);
}
})();
}
document.body.appendChild(root);
boot();
});
}
+153
View File
@@ -0,0 +1,153 @@
// OneBSpace (OneCAD) — odoo host bridge port
/**
* (C) OneBSpace — https://1bspace.com — Proprietary.
* OneCAD odoo/index.ts — the boot glue (bridge <-> explorer <-> Rust app).
* ----------------------------------------------------------------------
* Entry module loaded by index.html ONLY inside the wasm_app_framework
* iframe (standalone runs never import it — see the import guard there).
* Responsibilities:
*
* 1. instantiate the bridge singleton and wire the explorer dialogs
* ("saveAs" / "open" / "export" / "importAsset" host-request methods);
* 2. BOOT GATE: waitForHost() (ping every 100 ms, up to 20 s) — then, if
* location.search carries ?document=<id>, read that record BEFORE the
* app is signaled ready (deep-link open, "onecad:document-opened");
* 3. signal "onecad:host-ready" {host, documentId, name} exactly once.
* The same state stays readable synchronously at window.onecadHost
* (late-booting Rust) together with consumePendingOpen(), which hands
* the deep-link payload {documentId, name, dataB64} over once.
*
* Standalone dev mode: no host frame — no waiting, no deep link, the ready
* signal still fires with host:false so the app boots untouched.
*
* Build note: scripts/build_onecad_addon.py compiles this file (erasable-
* syntax-only TS) to odoo/index.js next to bridge.js; explorer.js is a
* plain-JS module copied verbatim. Exports nothing.
*/
import { exportForHost, EVT_HOST_READY } from "./bridge.js?v=20.0.1.0.6";
// @ts-ignore — explorer.js is a sibling plain-JS ES module (copied verbatim
// into dist; it imports the compiled bridge.js at runtime).
import { openDocumentsExplorer } from "./explorer.js?v=20.0.1.0.6";
import type { DocumentOpenedDetail, ExplorerOpener, HostReadyDetail } from "./bridge.js";
const bridge = exportForHost();
bridge.setExplorerOpener(openDocumentsExplorer as ExplorerOpener);
/* Late-boot contract surface (Rust registers listeners after this module
* has run, so it polls this synchronously-readable state on init). */
interface OneCadHostWindow {
onecadHostReady?: boolean;
onecadHost?: {
ready: boolean;
host: boolean;
documentId: number | null;
name: string | null;
consumePendingOpen: () => DocumentOpenedDetail | null;
};
solidcraft?: { request?: (method: string, params?: unknown) => Promise<unknown> };
onecadOdoo?: {
bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined;
};
}
const hostWindow = window as unknown as OneCadHostWindow;
hostWindow.onecadHost = {
ready: false,
host: false,
documentId: null,
name: null,
consumePendingOpen: () => bridge.consumePendingOpen(),
};
/* services_odoo.rs polls window.onecadHostReady (a plain boolean) before
* dispatching odoo.pickOpen / odoo.pickSave / odoo.write to this glue.
* Set it as soon as the boot attempt finishes (host answered or not): every
* Rust picker parks its slot behind this flag, so an unset flag wedges the
* app's file dialogs for REQUEST_TIMEOUT (120 s) with no dialog on screen. */
function markHostReady(): void {
try {
hostWindow.onecadHostReady = true;
} catch (error) {
console.warn("[onecad-odoo] could not set onecadHostReady:", error);
}
}
/* Deep-link opener: stage the record bytes into the engine VFS via the
* window.onecadOdoo.bootFile seam (installed by services_odoo.rs), then ask
* the app to open the staged path through its control API. The wasm app API
* appears asynchronously after boot, so poll briefly for it. */
async function openStagedDeepLink(entry: DocumentOpenedDetail | null): Promise<void> {
if (!entry || typeof entry.dataB64 !== "string") return;
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
const api = hostWindow.solidcraft;
const seam = hostWindow.onecadOdoo;
if (api && typeof api.request === "function" && seam && typeof seam.bootFile === "function") {
try {
const bytes = Uint8Array.from(atob(entry.dataB64), (char) => char.charCodeAt(0));
const path = seam.bootFile(entry.documentId, entry.name, bytes);
if (typeof path === "string" && path) {
await api.request("ui.file", { path });
return;
}
} catch (error) {
console.warn(
"[onecad-odoo] deep-link open failed:",
error instanceof Error ? error.message : error,
);
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
console.warn("[onecad-odoo] deep-link open skipped: the app API never appeared");
}
void (async () => {
let host = false;
if (bridge.inFrame) {
try {
await bridge.waitForHost();
host = true;
} catch (error) {
console.warn(
"[onecad-odoo] host did not answer in time — continuing without Odoo Documents:",
error,
);
}
} else {
console.info("[onecad-odoo] standalone (no host frame) — Odoo Documents disabled");
}
/* Unblock the Rust pickers first; the deep-link read continues in the
* background and must never keep the file dialogs hostage. */
markHostReady();
let deepLink = false;
if (host) {
try {
deepLink = await bridge.openDeepLink();
} catch (error) {
console.warn("[onecad-odoo] deep-link read failed:", error);
}
}
const binding = bridge.getCurrentBinding();
const detail: HostReadyDetail = {
host,
documentId: binding.documentId,
name: binding.name,
};
const state = hostWindow.onecadHost;
if (state) {
state.ready = true;
state.host = host;
state.documentId = detail.documentId;
state.name = detail.name;
}
window.dispatchEvent(new CustomEvent<HostReadyDetail>(EVT_HOST_READY, { detail }));
if (deepLink) {
void openStagedDeepLink(bridge.consumePendingOpen());
}
})();
export {};
+389
View File
@@ -0,0 +1,389 @@
/**
* (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
* OneBSpace (OneCAD): odoo/onecad-odoo.js — the app-side host glue.
* ---------------------------------------------------------------------
* Entry module loaded by index.html ONLY inside a frame (standalone runs
* never import it). It is the middle layer between the Rust app
* (apps/solidcraft-web/src/services_odoo.rs, which installs
* `window.onecadOdoo` — the resolve/bootFile glue) and the wasm_app_
* framework Documents bridge (odoo/bridge.ts compiled to bridge.js):
*
* request(id, kind, payloadJson) Rust picker → explorer → resolve
* kind "open" → Documents explorer → bridge.read → Rust inserts
* bytes at /odoo/doc/<recordId>/<name>
* kind "save" → Documents explorer (Save) → token → Rust returns
* /odoo/pending/<token>/<name>
* kind "program" → asset explorer (Assets folder) → like "open"
* write(path, bytes) VFS drain → bridge write / saveExport
* /odoo/doc/<recordId>/<name> → bridge.write({recordId}) in place
* /odoo/pending/<token>/<name> → token meta (localStorage) → first
* write creates the record, later
* writes hit the same record (Ctrl+S)
* /downloads/<name> → bridge.saveExport into "Exports"
* import(name, bytes) computer-imported CAD assets mirrored
* into the "Assets" folder (sig-deduped)
*
* Path schemes on the emulated filesystem (the one-photo conventions):
* /odoo/doc/<recordId>/<name> an existing Documents record
* /odoo/pending/<token>/<name> a chosen destination not yet written
* /downloads/<name> an export → Documents "Exports"
*
* Deep-link boot gate: the shell's iframe URL carries ?document=<id> when
* the app was opened by clicking an owned file in Documents. The record is
* read, handed to Rust (bootFile inserts it into the engine's VFS at
* /odoo/doc/...) and opened like any opened file (doc.open / inserts).
*/
import {
bridge,
base64ToBytes,
bytesToBase64,
guardCap,
extensionOf,
sanitizeStem,
withExtension,
} from "./bridge.js";
import { openDocumentsExplorer } from "./explorer.js";
/* localStorage stores (the binding cache; Documents stays the truth) */
const PENDING_KEY = "one-cad:odoo:pending-tokens";
const IMPORT_SIGS_KEY = "one-cad:odoo:import-sigs";
const SAVE_FOLDER_KEY = "one-cad:documents:last-folder:save";
const EXPORT_FOLDER_KEY = "one-cad:documents:last-folder:export";
const OPEN_FOLDER_KEY = "one-cad:documents:last-folder:open";
/* Commands the app understands on an emulated path (io crate naming). */
const DESIGN_EXTENSIONS = new Set(["solidcraft", "solvecraft", "json"]);
const STEP_EXTENSIONS = new Set(["step", "stp", "iges", "igs", "brep"]);
const MESH_EXTENSIONS = new Set(["stl", "obj", "3mf", "gltf", "glb", "ply"]);
const ASSET_EXTENSIONS = new Set([
...STEP_EXTENSIONS,
...MESH_EXTENSIONS,
"dxf", "svg", "urdf",
]);
function embedded() {
try {
if (typeof window === "undefined" || !window.parent || window.parent === window) return false;
// Same-origin shell under the Odoo web client (family env.ts rule).
const path = window.parent.location.pathname;
return path.startsWith("/odoo") || path.startsWith("/web") || /[\?&]odoo-host=1/.test(window.location.search);
} catch (e) {
// Cross-origin parent cannot happen in the same-origin shell; treat any
// throw (parent probe blocked) as framed.
return true;
}
}
/* ------------------------------------------------------------------ */
/* localStorage stores */
/* ------------------------------------------------------------------ */
function loadMap(key) {
try {
const raw = window.localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : {};
return parsed && typeof parsed === "object" ? parsed : {};
} catch (e) {
return {};
}
}
function saveMap(key, value) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
/* private mode / quota — memory only for this session */
}
}
let tokens = loadMap(PENDING_KEY);
function saveTokens() {
saveMap(PENDING_KEY, tokens);
}
function newToken() {
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/** Pending-save tokens survive reloads so Ctrl+S keeps hitting the same
* record even after the token map left memory (one-photo convention). */
function tokenOf(path) {
const rest = path.slice("/odoo/pending/".length);
const slash = rest.indexOf("/");
return slash === -1 ? rest : rest.slice(0, slash);
}
function baseName(path) {
const withoutSlash = path.replace(/\/+$/, "");
const slash = withoutSlash.lastIndexOf("/");
return slash === -1 ? withoutSlash : withoutSlash.slice(slash + 1);
}
/* ------------------------------------------------------------------ */
/* Rust glue plumbing */
/* ------------------------------------------------------------------ */
/** The Rust side installs window.onecadOdoo (services_odoo.rs). Requests
* arriving before it are queued — boot-order races are impossible. */
function rustGlue() {
return window.onecadOdoo || null;
}
const requestQueue = [];
function dispatchRequest(id, kind, payloadJson) {
if (!rustGlue()) {
// The Rust glue is not installed yet (wasm still booting): queue it and
// retry — boot-order races are impossible.
requestQueue.push([id, kind, payloadJson]);
window.setTimeout(pumpQueue, 250);
return;
}
void handleRequest(id, kind, payloadJson);
}
function pumpQueue() {
if (!rustGlue()) return;
while (requestQueue.length) {
const [id, kind, payloadJson] = requestQueue.shift();
void handleRequest(id, kind, payloadJson);
}
}
async function handleRequest(id, kind, payloadJson) {
let payload = {};
try {
payload = payloadJson ? JSON.parse(payloadJson) : {};
} catch (e) {
payload = {};
}
try {
await bridge.waitForHost();
if (kind === "save") {
const design = !!payload.design;
const picked = await openDocumentsExplorer({
mode: "save",
defaultName: typeof payload.name === "string" && payload.name ? payload.name : "part.solidcraft",
preferAppKey: design ? "projects" : "exports",
lastFolderKey: design ? SAVE_FOLDER_KEY : EXPORT_FOLDER_KEY,
});
if (!picked || typeof picked.folderId !== "number") {
rustGlue().resolve(id, null); // cancelled — not an error
return;
}
const extension = extensionOf(payload.name || "");
const name = withExtension(picked.name, extension);
const token = newToken();
tokens[token] = { folderId: picked.folderId, name };
saveTokens();
rustGlue().resolve(id, { token, name });
return;
}
// "open" and "program": pick a record, read it, hand the bytes over.
const preferAppKey = kind === "program" ? "assets" : "projects";
const picked = await openDocumentsExplorer({
mode: "open",
extensions: Array.isArray(payload.extensions) ? payload.extensions : [],
preferAppKey,
lastFolderKey: OPEN_FOLDER_KEY,
});
if (!picked || typeof picked.recordId !== "number") {
rustGlue().resolve(id, null);
return;
}
const record = await bridge.read(picked.recordId);
const bytes = base64ToBytes(record.dataB64);
guardCap(bytes.length, record.name);
// Foreign assets (outside the app tree) are copied into "Assets" so
// imports always land there, like the family storage rules require.
void mirrorToAssets(picked.recordId, record.name, bytes);
rustGlue().resolve(id, { recordId: picked.recordId, name: record.name, bytes });
} catch (error) {
console.error("[one-cad odoo] request failed:", error);
try {
if (rustGlue()) rustGlue().resolve(id, null);
} catch (e) {
/* the Rust future times out on its own */
}
}
}
/* ------------------------------------------------------------------ */
/* VFS writes → Documents (saves + exports) */
/* ------------------------------------------------------------------ */
async function handleWrite(path, bytes) {
await hostReady;
const dataB64 = bytesToBase64(bytes);
if (path.indexOf("/odoo/doc/") === 0) {
// /odoo/doc/<recordId>/<name> — in-place write-back (Ctrl+S).
const rest = path.slice("/odoo/doc/".length);
const recordId = Number(rest.split("/")[0]);
if (!Number.isInteger(recordId) || recordId <= 0) throw new Error("bad document path: " + path);
await bridge.write({ recordId, dataB64 });
return;
}
if (path.indexOf("/odoo/pending/") === 0) {
// /odoo/pending/<token>/<name> — first write creates the record, then
// the token binds to it forever (the document_id → recordId session
// map, kept in localStorage so reloads keep writing in place).
const token = tokenOf(path);
const meta = tokens[token];
if (!meta) throw new Error("unknown save destination (stale token): " + token);
const bound = Number.isInteger(meta.recordId) && meta.recordId > 0;
const written = await bridge.write({
recordId: bound ? meta.recordId : undefined,
name: meta.name,
folderId: bound ? undefined : meta.folderId,
dataB64,
extension: bound ? undefined : extensionOf(meta.name),
});
if (!bound) {
meta.recordId = written.id;
saveTokens();
}
return;
}
if (path.indexOf("/downloads/") === 0) {
// Exports: no local device writes in the Odoo shell — every download
// path is rerouted into the app's "Exports" folder.
await bridge.saveExport({ name: baseName(path), dataB64 });
return;
}
console.warn("[one-cad odoo] ignoring VFS write outside the Odoo schemes:", path);
}
/* ------------------------------------------------------------------ */
/* Asset imports (computer → "Assets" folder) */
/* ------------------------------------------------------------------ */
let appTreeCache = null;
async function appAssetsFolder() {
if (!appTreeCache) {
const ping = await bridge.waitForHost();
appTreeCache = (ping && ping.app) || {};
}
return typeof appTreeCache.assets === "number" ? appTreeCache.assets : appTreeCache.root;
}
/** Copy a foreign record (picked from outside the app tree) into Assets. */
async function mirrorToAssets(recordId, name, bytes) {
try {
if (typeof recordId !== "number" || DESIGN_EXTENSIONS.has(extensionOf(name))) return;
const documents = await bridge.list();
if ((documents.documents || []).some((doc) => Number(doc.id) === Number(recordId))) return; // already ours
await writeAsset(name, bytes);
} catch (error) {
console.warn("[one-cad odoo] asset mirror skipped:", error);
}
}
async function writeAsset(name, bytes) {
guardCap(bytes.length, name);
const folderId = await appAssetsFolder();
const ext = extensionOf(name) || "bin";
await bridge.write({
name: sanitizeStem(name),
dataB64: bytesToBase64(bytes),
folderId,
extension: ext,
});
}
/** Files brought in from the computer (files.rs incoming) land in Assets. */
async function handleImport(name, bytes) {
await hostReady;
const sigs = loadMap(IMPORT_SIGS_KEY);
const sig = name + ":" + bytes.length;
if (sigs[sig]) return;
sigs[sig] = 1;
saveMap(IMPORT_SIGS_KEY, sigs);
try {
if (!ASSET_EXTENSIONS.has(extensionOf(name))) return;
await writeAsset(name, bytes);
} catch (error) {
console.warn("[one-cad odoo] import into Assets failed:", error);
}
}
/* ------------------------------------------------------------------ */
/* Boot: handshake, deep link (?document=<id>), host-ready promise */
/* ------------------------------------------------------------------ */
let hostReadyResolve;
const hostReady = new Promise((resolve) => {
hostReadyResolve = resolve;
});
/** Open command for an emulated path, mirroring the app's own Open logic:
* designs open, CAD formats are inserted, everything else just waits in
* the VFS (the bytes are at /odoo/doc/… and stay retrievable). */
function openCommandFor(name) {
const ext = extensionOf(name);
if (DESIGN_EXTENSIONS.has(ext)) return { command: "doc.open" };
if (STEP_EXTENSIONS.has(ext)) return { command: "file.insert_step" };
if (MESH_EXTENSIONS.has(ext)) return { command: "file.insert_mesh" };
if (ext === "dxf" || ext === "svg") return { command: "sketch.insert_" + ext };
return null;
}
async function boot() {
if (!embedded()) return;
try {
await bridge.waitForHost();
} catch (error) {
console.warn("[one-cad odoo] host handshake failed:", error);
hostReadyResolve(false); // fail writes loudly instead of hanging
return;
}
hostReadyResolve(true);
pumpQueue();
const documentId = bridge.bootDocumentId();
if (!documentId) return;
try {
const record = await bridge.read(documentId);
const bytes = base64ToBytes(record.dataB64);
guardCap(bytes.length, record.name);
// bootFile inserts the bytes into the engine VFS and returns the path.
const path = rustGlue().bootFile(documentId, record.name, bytes);
const open = openCommandFor(record.name);
if (open && window.solidcraft && typeof window.solidcraft.execute === "function") {
await window.solidcraft.execute(open.command, { path });
} else if (!open) {
console.info("[one-cad odoo] deep-linked file staged at", path);
}
} catch (error) {
console.error("[one-cad odoo] deep-link boot failed:", error);
}
}
/* ------------------------------------------------------------------ */
/* Public surface (called from services_odoo.rs) */
/* ------------------------------------------------------------------ */
window.onecadOdooJs = {
/** True once this module decided the app runs in the Odoo shell. */
embedded: embedded(),
/** Rust picker request: kind "open" | "save" | "program". */
request(id, kind, payloadJson) {
dispatchRequest(Number(id), String(kind || ""), payloadJson ? String(payloadJson) : "{}");
},
/** VFS write drained by services_odoo.rs (fire-and-forget save). */
write(path, bytes) {
handleWrite(String(path || ""), new Uint8Array(bytes)).catch((error) => {
console.error("[one-cad odoo] save to Documents failed:", path, error);
});
},
/** A CAD asset imported from the computer (mirrored into Assets). */
import(name, bytes) {
handleImport(String(name || ""), new Uint8Array(bytes)).catch((error) => {
console.warn("[one-cad odoo] import failed:", name, error);
});
},
};
if (window.onecadOdooJs.embedded) {
void boot();
}
+595
View File
@@ -0,0 +1,595 @@
// OneBSpace (OneCAD): Odoo Documents host integration.
//! OneBSpace (OneCAD): Odoo Documents host integration for the wasm32 build
//! (the `mod services_odoo;` declaration in `main.rs` carries the
//! `#[cfg(target_arch = "wasm32")]` gate).
//!
//! Inside the wasm_app_framework shell every host seam of
//! [`solidcraft_ui_egui::Services`] is rerouted through the one_cad JS
//! bridge instead of the browser/device:
//!
//! - `pick_open` / `pick_save` / `pick_program` ask the JS host glue to show
//! the unified Documents explorer and resolve with an *emulated* VFS path:
//! `/odoo/doc/<recordId>/<name>` (an opened Documents record, bytes staged
//! in the engine VFS) or `/odoo/pending/<token>/<name>` (a Save-as
//! destination; the JS side keeps the token → record binding so a later
//! Ctrl+S writes back in place).
//! - writes drained from the engine VFS under `/odoo/…` go to the bound
//! Documents record and `/downloads/` exports go to the "Exports" folder —
//! the air gap keeps bytes off the local device ([`handle_write`]).
//! - `show_in_folder` is a no-op and `http_get` stays `None` (air gap: the
//! shell has no local filesystem to reveal and no outbound HTTP).
//!
//! Everything is inert standalone: when the page is not framed by the Odoo
//! shell ([`embedded`]) nothing is overridden and the upstream behavior is
//! untouched.
//!
//! # WIRE PROTOCOL (matches the one_cad JS bridge: CustomEvents)
//!
//! Rust → JS: `window.dispatchEvent(new CustomEvent("onecad:host-request",
//! { detail }))` with `detail = { id: number, method: string, params: object }`.
//!
//! JS → Rust: `window.dispatchEvent(new CustomEvent("onecad:host-response",
//! { detail }))` with `detail = { id, ok: boolean, result?: any, error?: string }`.
//!
//! The glue must attach its `onecad:host-response` listener *before* the app
//! boots (index.html loads the bridge module ahead of the wasm) and set
//! `window.onecadHostReady = true` when listening; until the flag appears
//! requests are retried every [`RETRY_MS`] for [`HOST_DEADLINE`].
//!
//! | method | params | result (on `ok`) |
//! |--------------------|------------------------------------------|------------------|
//! | `odoo.pickOpen` | `{ extensions: string[] }` | `{ recordId, name, bytes: Uint8Array }` or `{ content: <base64> }` → bytes staged at `/odoo/doc/<recordId>/<name>` |
//! | `odoo.pickSave` | `{ name: string, design: boolean }` | `{ token, name }` → `/odoo/pending/<token>/<name>`, or `{ path: "/odoo/…" }` |
//! | `odoo.pickProgram` | `{}` (asset pick from the "Assets" folder) | same shape as `odoo.pickOpen` |
//! | `odoo.write` | `{ path: string, bytes: Uint8Array }` | ack only (`ok`) — Documents record write-back |
//! | `odoo.saveExport` | `{ name: string, bytes: Uint8Array }` | ack only — lands in the "Exports" folder |
//! | `odoo.import` | `{ name: string, bytes: Uint8Array }` | ack only — best-effort mirror into "Assets" |
//!
//! A `null`/absent `result` with `ok: true` means the user cancelled the
//! explorer; the picker future resolves `None` (no error).
//!
//! For JS→Rust binary staging (deep links `?document=<id>`) the glue may
//! call `window.onecadOdoo.bootFile(recordId, name, uint8array) -> path`,
//! which inserts the bytes into the engine VFS at `/odoo/doc/<id>/<name>`.
//!
//! # HOW TO WIRE (exact locations in the current tree)
//!
//! 1. `apps/solidcraft-web/src/main.rs`, module block (tagged
//! `OneBSpace (OneCAD)`): `#[cfg(target_arch = "wasm32")] mod services_odoo;`
//! 2. `apps/solidcraft-web/src/main.rs`, inside `web::start()`'s
//! `WebRunner::start` app-install closure — immediately after the
//! `let services = Services { .. };` literal and **before**
//! `SolveApp::new(.., services)` (the tagged line
//! `// OneBSpace (OneCAD): install odoo services on wasm`,
//! currently main.rs line 537):
//! `let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());`
//! 3. **Exact VFS write drain point**: `apps/solidcraft-web/src/files.rs`
//! `flush()` — the `for (path, bytes) in vfs::take_writes()` loop
//! (files.rs line 297) hands every write to
//! `crate::services_odoo::handle_write(&path, &bytes)` *first*
//! (files.rs line 301); `true` consumes it (never OPFS, never a device
//! download). Writes are enqueued by `vfs::write()`
//! (crates/io/src/vfs.rs, wasm32 `imp::write`, line 84, which pushes the
//! path onto the `WRITES` queue) and drained by `imp::take_writes()`
//! (vfs.rs line 109).
//! 4. `apps/solidcraft-web/src/main.rs` line 130 (`Shell::files_tick`):
//! `crate::services_odoo::mirror_imports(&self.files)` mirrors CAD
//! imports from the computer into the Documents "Assets" folder.
// OneBSpace (OneCAD) — this entire file is downstream; keep it out of
// upstream syncs (greppable tag above).
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::task::{Context as TaskContext, Poll, Wake, Waker};
use std::time::Duration;
// OneBSpace (OneCAD): std::time::Instant::now() PANICS on
// wasm32-unknown-unknown ("time not implemented on this platform") — the
// whole app loop died the moment a picker (Open/Save/Import/Export) ran.
// web_time::Instant maps to performance.now() on wasm, std on native.
use web_time::Instant;
use js_sys::{Array, Object, Reflect, Uint8Array};
use solidcraft_engine::io::vfs;
use solidcraft_ui_egui::{FilePickerFuture, Services};
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::JsFuture;
use crate::files::{self, Files};
/// An existing Documents record inside the emulated filesystem.
const ODOC: &str = "/odoo/doc/";
/// A Save-as destination chosen in the explorer, not yet written.
const OPENDING: &str = "/odoo/pending/";
/// Rust → JS request event (see the WIRE PROTOCOL block above).
const REQUEST_EVENT: &str = "onecad:host-request";
/// JS → Rust response event (see the WIRE PROTOCOL block above).
const RESPONSE_EVENT: &str = "onecad:host-response";
const PICK_OPEN: &str = "odoo.pickOpen";
const PICK_SAVE: &str = "odoo.pickSave";
const PICK_PROGRAM: &str = "odoo.pickProgram";
const WRITE: &str = "odoo.write";
const SAVE_EXPORT: &str = "odoo.saveExport";
const IMPORT: &str = "odoo.import";
/// At most this many bridge requests parked at once (the api.rs convention).
const MAX_PENDING: usize = 64;
/// A stuck shell (bridge JS missing, explorer broken) must not wedge the
/// app's file picker forever: the future cancels itself after this long.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
/// Retry cadence while `window.onecadHostReady` has not appeared yet.
const RETRY_MS: i32 = 250;
/// How long to wait for the JS bridge module to become ready per request.
const HOST_DEADLINE: Duration = Duration::from_secs(30);
/// CAD formats the app ingests (mirrors files.rs's input picker accept list
/// plus glTF/URDF). Imported copies land in the Documents "Assets" folder.
const ASSET_EXTS: [&str; 12] = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
thread_local! {
/// Decided once: does this page run inside the Odoo shell?
static EMBEDDED: Cell<Option<bool>> = const { Cell::new(None) };
/// Pending picker requests, keyed by request id (the api.rs pending
/// queue, adapted to Rust futures instead of JS promises).
static PENDING: RefCell<HashMap<u64, Arc<Slot>>> = RefCell::new(HashMap::new());
/// The request-id counter.
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
/// The `onecad:host-response` listener is installed exactly once.
static LISTENER: Cell<bool> = const { Cell::new(false) };
/// The `window.onecadOdoo` boot seam is installed exactly once.
static BOOT: Cell<bool> = const { Cell::new(false) };
/// Asset imports already mirrored this session (name, size).
static MIRRORED: RefCell<HashSet<(String, usize)>> = RefCell::new(HashSet::new());
}
/// A picker answer slot: the response listener fills it, the future drains it.
struct Slot {
done: Cell<bool>,
value: RefCell<Option<String>>,
waker: RefCell<Option<Waker>>,
}
impl Slot {
fn complete(&self, value: Option<String>) {
if self.done.get() {
return;
}
*self.value.borrow_mut() = value;
self.done.set(true);
if let Some(waker) = self.waker.borrow_mut().take() {
waker.wake();
}
}
}
/// The egui repaint waker (same pattern as ui-egui's own file picker).
struct Repaint(egui::Context);
impl Wake for Repaint {
fn wake(self: Arc<Self>) {
self.0.request_repaint();
}
}
/// The future a `Services` picker closure returns; resolves with the
/// emulated path once the shell answers, or `None` when cancelled/expired.
struct PickerFuture {
slot: Arc<Slot>,
deadline: Instant,
}
impl Future for PickerFuture {
type Output = Option<String>;
fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
if this.slot.done.get() {
return Poll::Ready(this.slot.value.borrow_mut().take());
}
if Instant::now() >= this.deadline {
this.slot.complete(None);
return Poll::Ready(None);
}
*this.slot.waker.borrow_mut() = Some(cx.waker().clone());
Poll::Pending
}
}
/// Is this page running inside the wasm_app_framework shell? Decided once,
/// the family rule (one-cut-pro env.ts): framed and the same-origin parent
/// is the Odoo web client (`/odoo` or `/web`). `?odoo-host=1` forces it on
/// for local testing against a stub shell.
pub fn embedded() -> bool {
if let Some(decided) = EMBEDDED.with(Cell::get) {
return decided;
}
let decided = embedded_uncached();
EMBEDDED.with(|e| e.set(Some(decided)));
decided
}
fn embedded_uncached() -> bool {
let Some(window) = web_sys::window() else { return false };
let Ok(search) = window.location().search() else { return false };
if search.contains("odoo-host=1") {
return true;
}
let Ok(parent) = window.parent() else { return false };
// Top window: standalone run, upstream behavior everywhere.
let parent_top = parent
.as_ref()
.map(|p| {
AsRef::<JsValue>::as_ref(p) == AsRef::<JsValue>::as_ref(&window)
})
.unwrap_or(false);
if parent_top {
return false;
}
// Same-origin shell: the parent path is readable; anything else that is
// framed counts as embedded (the shell never cross-origins the iframe).
let pathname = parent
.as_ref()
.and_then(|p| p.location().pathname().ok());
match pathname {
Some(path) => path.starts_with("/odoo") || path.starts_with("/web"),
None => true,
}
}
/// Install the integration: the boot seam and the response listener always
/// (harmless standalone), the Services overrides only inside the shell.
/// Call once at app start, right after the `Services` literal is built in
/// `main.rs` and before `SolveApp::new` (see HOW TO WIRE above).
pub fn install_odoo_services(services: Services, ctx: egui::Context) -> Services {
install_boot_seam();
install_response_listener(ctx.clone());
if !embedded() {
return services;
}
log::info!("OneCAD: Odoo shell detected — pickers and writes route through the Documents bridge");
let open_ctx = ctx.clone();
let save_ctx = ctx.clone();
let program_ctx = ctx;
Services {
// Open/Insert pickers browse Odoo Documents (explorer, then a read).
pick_open: Some(Box::new(move |extensions: &[&str]| {
let params = Object::new();
let list = Array::new();
for ext in extensions {
list.push(&JsValue::from_str(ext));
}
let _ = Reflect::set(&params, &"extensions".into(), &list.into());
pick(PICK_OPEN, params, open_ctx.clone())
})),
// Saves ask the explorer once; the returned pending token binds the
// design to its record (later Ctrl+S writes back in place).
pick_save: Some(Box::new(move |name: &str, extensions: &[&str]| {
let params = Object::new();
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
// Same design test as the upstream closure (main.rs).
let design = extensions.contains(&solidcraft_engine::io::DESIGN_EXT);
let _ = Reflect::set(&params, &"design".into(), &JsValue::from_bool(design));
pick(PICK_SAVE, params, save_ctx.clone())
})),
// The slicer program pick becomes an asset pick from the "Assets"
// folder: the picked file is staged in the VFS (no host program
// dialog exists in the shell).
pick_program: Some(Box::new(move || pick(PICK_PROGRAM, Object::new(), program_ctx.clone()))),
// Air gap: nothing local to reveal.
show_in_folder: Some(Box::new(|_: &str| {})),
// Air gap: no update checks over HTTP in the shell.
http_get: None,
..services
}
}
/// Park a picker slot and deliver the request to the shell; the returned
/// future resolves with the emulated path (or `None` on cancel/timeout).
fn pick(method: &'static str, params: Object, ctx: egui::Context) -> FilePickerFuture {
if PENDING.with(|p| p.borrow().len()) >= MAX_PENDING {
log::error!("OneCAD: too many pending bridge requests (maximum {MAX_PENDING})");
return Box::pin(std::future::ready(None));
}
let id = next_id();
let slot = Arc::new(Slot { done: Cell::new(false), value: RefCell::new(None), waker: RefCell::new(None) });
PENDING.with(|p| p.borrow_mut().insert(id, slot.clone()));
deliver(method, id, params, slot.clone());
// The api.rs pattern: nudge the UI so the future is polled promptly.
ctx.request_repaint();
Box::pin(PickerFuture { slot, deadline: Instant::now() + REQUEST_TIMEOUT })
}
fn next_id() -> u64 {
NEXT_ID.with(|n| {
let id = n.get();
n.set(id + 1);
id
})
}
/// Deliver a request once `window.onecadHostReady` appears (the glue sets it
/// after attaching its response listener), then cancel the slot if it never
/// does. Answers arrive out-of-band via `onecad:host-response`.
fn deliver(method: &'static str, id: u64, params: Object, slot: Arc<Slot>) {
wasm_bindgen_futures::spawn_local(async move {
let deadline = Instant::now() + HOST_DEADLINE;
loop {
if slot.done.get() {
return; // answered or cancelled while we were waiting
}
if host_ready() && dispatch_event(method, id, &params) {
return; // the shell owns the answer now
}
if Instant::now() >= deadline {
log::error!("OneCAD: the Odoo bridge never became ready; cancelling \"{method}\"");
slot.complete(None);
return;
}
sleep_ms(RETRY_MS).await;
}
});
}
fn host_ready() -> bool {
web_sys::window()
.map(|w| Reflect::get(&w, &"onecadHostReady".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false))
.unwrap_or(false)
}
async fn sleep_ms(ms: i32) {
let Some(window) = web_sys::window() else { return };
let promise = js_sys::Promise::new(&mut |resolve, _| {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
});
let _ = JsFuture::from(promise).await;
}
/// `window.dispatchEvent(new CustomEvent(method, { detail: {id, method, params} }))`.
/// Built through `js_sys` (no extra web-sys feature gates). `false` when the
/// event could not be created or dispatched (no shell listening).
fn dispatch_event(method: &str, id: u64, params: &Object) -> bool {
let Some(window) = web_sys::window() else { return false };
let Ok(ctor) = Reflect::get(&window, &"CustomEvent".into()) else { return false };
let Ok(ctor) = ctor.dyn_into::<js_sys::Function>() else { return false };
let detail = Object::new();
let _ = Reflect::set(&detail, &"id".into(), &JsValue::from(id as f64));
let _ = Reflect::set(&detail, &"method".into(), &JsValue::from_str(method));
let _ = Reflect::set(&detail, &"params".into(), params);
let init = Object::new();
let _ = Reflect::set(&init, &"detail".into(), &detail.into());
let args = Array::of2(&JsValue::from_str(REQUEST_EVENT), &init.into());
let Ok(event) = Reflect::construct(&ctor, &args) else { return false };
let Ok(dispatch) = Reflect::get(&window, &"dispatchEvent".into()) else { return false };
let Ok(dispatch) = dispatch.dyn_into::<js_sys::Function>() else { return false };
dispatch.call1(&window, &event).is_ok()
}
/// Install the `onecad:host-response` listener once: responses complete the
/// parked picker slots (or log write/import failures).
fn install_response_listener(ctx: egui::Context) {
if LISTENER.with(Cell::get) {
return;
}
let Some(window) = web_sys::window() else { return };
let listener = Closure::<dyn Fn(JsValue)>::new(move |event: JsValue| {
let detail = Reflect::get(&event, &"detail".into()).unwrap_or(JsValue::UNDEFINED);
let id = field_u64(&detail, "id");
let ok = Reflect::get(&detail, &"ok".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
let error = field_string(&detail, "error").unwrap_or_default();
let result = Reflect::get(&detail, &"result".into()).unwrap_or(JsValue::UNDEFINED);
let slot = PENDING.with(|p| p.borrow_mut().remove(&id));
match slot {
Some(slot) => {
if ok {
slot.complete(path_from_result(&result));
} else {
log::warn!("OneCAD: the shell refused the request: {error}");
slot.complete(None);
}
ctx.request_repaint();
}
// Writes, exports and imports are acknowledged without a parked
// slot; only their failures are interesting here.
None if !ok => log::warn!("OneCAD: the shell refused a write/export/import: {error}"),
None => {}
}
});
let Ok(add) = Reflect::get(&window, &"addEventListener".into()) else { return };
let Ok(add) = add.dyn_into::<js_sys::Function>() else { return };
if add.call2(&window, &JsValue::from_str(RESPONSE_EVENT), &listener.into_js_value()).is_ok() {
LISTENER.with(|l| l.set(true));
}
}
/// Map a `onecad:host-response` result onto the emulated path the picker
/// future receives (see the WIRE PROTOCOL table above).
fn path_from_result(result: &JsValue) -> Option<String> {
if !result.is_object() {
return None; // the user cancelled the explorer
}
let name = field_string(result, "name");
// An opened record: bytes come as a Uint8Array (preferred) or base64.
if let Ok(bytes) = Reflect::get(result, &"bytes".into())
&& bytes.is_instance_of::<Uint8Array>()
{
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return None };
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes.to_vec());
}
if let Some(content) = field_string(result, "content")
&& let Some(bytes) = base64_decode(&content)
{
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes);
}
// A Save-as destination: the pending token names the path the engine
// will write to (the glue binds the token to the record).
let token = field_string(result, "token");
if let (Some(token), Some(name)) = (token, name) {
return Some(format!("{OPENDING}{token}/{}", sanitize(&name)));
}
// Or the glue resolved an explicit emulated path itself.
if let Some(path) = field_string(result, "path")
&& path.starts_with("/odoo/")
{
return Some(path);
}
None
}
/// Stage a picked record's bytes in the engine VFS at `/odoo/doc/<id>/<name>`.
fn stage_record(record_id: u64, name: Option<&str>, bytes: Vec<u8>) -> Option<String> {
if record_id == 0 {
return None;
}
let path = format!("{ODOC}{record_id}/{}", sanitize(name.unwrap_or("document")));
vfs::insert(&path, bytes);
Some(path)
}
fn field_u64(value: &JsValue, key: &str) -> u64 {
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_f64()).map(|f| f as u64).unwrap_or(0)
}
fn field_string(value: &JsValue, key: &str) -> Option<String> {
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_string())
}
/// File names only key the VFS: keep them single-segment.
fn sanitize(name: &str) -> String {
let cleaned: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
let trimmed = cleaned.trim();
if trimmed.is_empty() {
"file".into()
} else {
trimmed.to_string()
}
}
/// Tiny base64 (standard alphabet, padding optional) decoder for bridge
/// payloads that arrive as `content` strings instead of `Uint8Array`s.
fn base64_decode(input: &str) -> Option<Vec<u8>> {
fn value(byte: u8) -> Option<u32> {
match byte {
b'A'..=b'Z' => Some((byte - b'A') as u32),
b'a'..=b'z' => Some((byte - b'a') as u32 + 26),
b'0'..=b'9' => Some((byte - b'0') as u32 + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let clean: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
for chunk in clean.chunks(4) {
if chunk.len() < 2 {
return None;
}
let mut acc: u32 = 0;
for (i, byte) in chunk.iter().enumerate() {
acc |= value(*byte)? << (18 - 6 * i);
}
out.push((acc >> 16) as u8);
if chunk.len() > 2 {
out.push((acc >> 8) as u8);
}
if chunk.len() > 3 {
out.push(acc as u8);
}
}
Some(out)
}
/// Drain hook (tagged call site in `files.rs::flush`, the exact
/// `vfs::take_writes()` drain point): consume writes the shell must not turn
/// into device IO. `/odoo/…` writes go to the bound Documents record (or the
/// pending token's destination on first write), `/downloads/` exports go to
/// the "Exports" folder via saveExport. Returns true when the write was
/// consumed (standalone: always false, upstream behavior untouched).
pub fn handle_write(path: &str, bytes: &[u8]) -> bool {
if !embedded() {
return false;
}
let export = path.strip_prefix(files::DOWNLOADS).map(str::to_string);
let is_odoo = path.starts_with(ODOC) || path.starts_with(OPENDING);
if !is_odoo && export.is_none() {
return false;
}
let params = Object::new();
if let Some(name) = &export {
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
} else {
let _ = Reflect::set(&params, &"path".into(), &JsValue::from_str(path));
}
let _ = Reflect::set(&params, &"bytes".into(), &Uint8Array::from(bytes).into());
let method = if export.is_some() { SAVE_EXPORT } else { WRITE };
if !dispatch_event(method, next_id(), &params) {
log::error!("OneCAD: the Odoo bridge is unavailable; \"{path}\" stays in the engine VFS (the Documents copy was not updated)");
}
// Consumed either way: the air gap wins even if the bridge module is
// gone — bytes never reach the local device from the shell (a copy
// survives in the engine VFS and the OPFS autosave until the save
// succeeds).
true
}
/// Mirror hook (tagged call site in `main.rs::files_tick`): CAD assets
/// brought in from the computer get a copy in the Documents "Assets" folder
/// (family storage rule). Deduplicated per (name, size) this session; the
/// JS side additionally dedupes by signature across reloads.
pub fn mirror_imports(files: &Rc<Files>) {
if !embedded() {
return;
}
for (name, bytes, _) in files.incoming.borrow().iter() {
if !is_asset_name(name) {
continue;
}
if !MIRRORED.with(|m| m.borrow_mut().insert((name.clone(), bytes.len()))) {
continue;
}
let params = Object::new();
let _ = Reflect::set(&params, &"name".into(), &JsValue::from_str(name));
let _ = Reflect::set(&params, &"bytes".into(), &Uint8Array::from(bytes.as_slice()).into());
if !dispatch_event(IMPORT, next_id(), &params) {
log::warn!("OneCAD: could not mirror \"{name}\" into the Assets folder (bridge not ready)");
}
}
}
fn is_asset_name(name: &str) -> bool {
std::path::Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ASSET_EXTS.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
}
/// Install `window.onecadOdoo = { bootFile }` — the JS→Rust staging seam for
/// deep links (`?document=<id>`): the glue inserts the record's bytes into
/// the engine VFS, then opens the returned path via `window.solidcraft`.
fn install_boot_seam() {
if BOOT.with(Cell::get) {
return;
}
let Some(window) = web_sys::window() else { return };
let boot_file = Closure::<dyn Fn(JsValue, JsValue, JsValue) -> JsValue>::new(
|record_id: JsValue, name: JsValue, bytes: JsValue| {
let id = record_id.as_f64().map(|f| f as u64).unwrap_or(0);
if id == 0 {
return JsValue::UNDEFINED;
}
let name = name.as_string().unwrap_or_else(|| "document".into());
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return JsValue::UNDEFINED };
match stage_record(id, Some(&name), bytes.to_vec()) {
Some(path) => JsValue::from_str(&path),
None => JsValue::UNDEFINED,
}
},
);
let glue = Object::new();
let _ = Reflect::set(&glue, &"bootFile".into(), &boot_file.into_js_value());
if Reflect::set(&window, &"onecadOdoo".into(), &glue).is_ok() {
BOOT.with(|b| b.set(true));
}
}
+25 -25
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. Hlášení odešlete na GitHubu tlačítkem Submit new issue.",
"A new configuration, a copy of the active one": "Nová konfigurace, kopie aktivní",
"AI": "AI",
"About SolidCraft": "O aplikaci SolidCraft",
"About OneCAD": "O aplikaci OneCAD",
"Active": "Aktivní",
"Active rule": "Aktivní pravidlo",
"Add": "Přidat",
@@ -239,7 +239,7 @@
"Sketch plane has no body-face attachment.": "Rovina náčrtu není připojena k ploše tělesa.",
"Sketch points": "Body náčrtu",
"Slot width": "Šířka drážky",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se zavřel dříve, než se tyto návrhy uložily. Lze otevřít jejich poslední automatické uložení.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD se zavřel dříve, než se tyto návrhy uložily. Lze otevřít jejich poslední automatické uložení.",
"Speed": "Rychlost",
"Stand it up": "Postavit",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Postavit: otočení o čtvrt otáčky kolem osy X jako jeden prvek Posunutí, který lze vrátit.",
@@ -673,18 +673,18 @@
"home.report_issue": "Nahlásit problém",
"home.samples": "Ukázky",
"home.whats_new": "Co je nového",
"install.button_tip": "Nainstalovat SolidCraft jako aplikaci: otevře se ve vlastním okně a funguje i offline",
"install.button_tip": "Nainstalovat OneCAD jako aplikaci: otevře se ve vlastním okně a funguje i offline",
"install.dock_1": "Zvolte Soubor › Přidat do Docku.",
"install.dock_2": "Klikněte na Přidat.",
"install.dock_after": "SolidCraft se pak otevírá z Docku ve vlastním okně a funguje i offline.",
"install.dock_after": "OneCAD se pak otevírá z Docku ve vlastním okně a funguje i offline.",
"install.done": "Hotovo",
"install.home_screen_1": "Klepněte na Sdílet (v nabídce …, pokud není na liště).",
"install.home_screen_2": "Zvolte Přidat na plochu.",
"install.home_screen_3": "Klepněte na Přidat.",
"install.home_screen_after": "SolidCraft se pak otevírá ikonou na ploše bez lišt Safari a funguje i offline.",
"install.home_screen_after": "OneCAD se pak otevírá ikonou na ploše bez lišt Safari a funguje i offline.",
"install.menu": "Nainstalovat aplikaci…",
"install.steps_intro": "Safari instaluje webové aplikace z vlastních nabídek:",
"install.title": "Instalace SolidCraftu",
"install.title": "Instalace OneCADu",
"joint.from_bodies_hint": "Každé vybrané těleso se přesune do vlastní nové komponenty a zůstane na místě.",
"joint.one_body": "Oba výběry jsou na {body}. Druhý vyberte na jiném dílu.",
"joint.one_component": "Tyto díly jsou tělesy jedné komponenty: {bodies}. Kloub pohybuje jednou komponentou vůči druhé, takže každý díl potřebuje vlastní komponentu.",
@@ -764,10 +764,10 @@
"settings.frame_rate_note": "Zobrazuje snímky za sekundu v pravém dolním rohu pohledu. Dokud je zapnuto, pohled se překresluje nepřetržitě.",
"settings.measure_axis_distances": "Zobrazit vzdálenosti X/Y/Z",
"settings.measure_axis_distances_help": "Při otevřeném měření zobrazit kromě celkové vzdálenosti i složky ve směru os.",
"settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění SolidCraftu",
"settings.msaa_applies_on_restart": "Vyhlazování se projeví při příštím spuštění OneCADu",
"settings.msaa_reset": "{setting}: {requested}× toto grafické zařízení nepodporuje; nyní je nastaveno {reset}× (podporováno: {supported}).",
"settings.msaa_unsupported": "Toto grafické zařízení nepodporuje",
"settings.reset_intro": "Tato nastavení na tomto počítači nefungují a bránila by spuštění SolidCraftu, proto byla změněna:",
"settings.reset_intro": "Tato nastavení na tomto počítači nefungují a bránila by spuštění OneCADu, proto byla změněna:",
"settings.reset_title": "Nastavení byla změněna",
"settings.supersampling_note": "Vykresluje pohled ve vyšším rozlišení a poté jej zmenšuje: hladší hrany, čáry a mřížka na obrazovkách pod 4K. Stojí výkon GPU: asi 2,25× více pixelů při 1,5× a 4× při 2×.",
"sketch.degrees_of_freedom": "Stupně volnosti: {count}",
@@ -814,8 +814,8 @@
"ui.agent_chat_attach_view": "Přiložit pohled",
"ui.agent_chat_attach_view_tip": "Odeslat se zprávou obrázek aktuálního pohledu",
"ui.agent_chat_auth_agent": "Přihlásit se přes {name}",
"ui.agent_chat_auth_env": "Nastavte {vars} v proměnných prostředí a restartujte SolidCraft.",
"ui.agent_chat_auth_needed": "Agent žádá o přihlášení. Přihlašujete se přímo u agenta; SolidCraft vaše heslo ani klíče nikdy nevidí.",
"ui.agent_chat_auth_env": "Nastavte {vars} v proměnných prostředí a restartujte OneCAD.",
"ui.agent_chat_auth_needed": "Agent žádá o přihlášení. Přihlašujete se přímo u agenta; OneCAD vaše heslo ani klíče nikdy nevidí.",
"ui.agent_chat_auth_none": "Agent zde nenabízí žádný způsob přihlášení: přihlaste se podle jeho vlastní dokumentace.",
"ui.agent_chat_auth_retry": "Přihlásil(a) jsem se: připojit znovu",
"ui.agent_chat_auth_terminal": "Spusťte toto v terminálu a pak znovu stiskněte Připojit:",
@@ -837,7 +837,7 @@
"ui.agent_chat_edit": "Upravit",
"ui.agent_chat_exited": "Agent se zastavil.",
"ui.agent_chat_failed_start": "Agenta se nepodařilo spustit: {error}",
"ui.agent_chat_intro": "Chatujte s kódovacím agentem, kterého již používáte, nebo s modelem na zvoleném endpointu. Pracuje na otevřeném návrhu pomocí nástrojů SolidCraftu: jeho změny vidíte průběžně a každou zprávu lze vrátit jedním krokem. Co smí dělat bez ptaní, nastavíte v Předvolby > AI.",
"ui.agent_chat_intro": "Chatujte s kódovacím agentem, kterého již používáte, nebo s modelem na zvoleném endpointu. Pracuje na otevřeném návrhu pomocí nástrojů OneCADu: jeho změny vidíte průběžně a každou zprávu lze vrátit jedním krokem. Co smí dělat bez ptaní, nastavíte v Předvolby > AI.",
"ui.agent_chat_key_in_env": "klíč nastaven",
"ui.agent_chat_left": "Vlevo",
"ui.agent_chat_limit": "Agent dosáhl limitu {limit} změn na jednu zprávu. Pošlete další zprávu, aby mohl pokračovat.",
@@ -858,7 +858,7 @@
"ui.agent_chat_placeholder": "Požádejte agenta o změnu návrhu…",
"ui.agent_chat_placeholder_busy": "Zařadit zprávu, až agent skončí (Ctrl+Enter: odeslat hned)",
"ui.agent_chat_privacy": "Nic se nikam neodesílá, jen agentovi, kterého spouštíte pod vlastním účtem.",
"ui.agent_chat_privacy_note": "SolidCraft nic nikam neodesílá: agent, kterého spouštíte, komunikuje se svou službou pod vaším účtem. SolidCraft nikdy nevidí ani neukládá klíč či heslo.",
"ui.agent_chat_privacy_note": "OneCAD nic nikam neodesílá: agent, kterého spouštíte, komunikuje se svou službou pod vaším účtem. OneCAD nikdy nevidí ani neukládá klíč či heslo.",
"ui.agent_chat_queue": "Zařadit",
"ui.agent_chat_queue_full": "Čeká příliš mnoho zpráv: některou odeberte nebo počkejte na agenta.",
"ui.agent_chat_queued": "Ve frontě",
@@ -897,7 +897,7 @@
"ui.ai_add_installed": "Nainstalovaný agent",
"ui.ai_add_installed_note": "Vlastní položka pro nainstalovaného agenta, například s jinými argumenty. Nainstalovaní agenti se v seznamu zobrazují sami.",
"ui.ai_added": "přidáno",
"ui.ai_agent_mode_tip": "Vlastní režim agenta: řídí se jím jeho vlastní nástroje (shell, úpravy souborů). Nástroje SolidCraftu se řídí oprávněními v Předvolby > AI.",
"ui.ai_agent_mode_tip": "Vlastní režim agenta: řídí se jím jeho vlastní nástroje (shell, úpravy souborů). Nástroje OneCADu se řídí oprávněními v Předvolby > AI.",
"ui.ai_agents": "Agenti",
"ui.ai_always_allow": "Vždy povolit",
"ui.ai_always_allow_tip": "Od teď povolit {tool} tomuto agentovi bez ptaní",
@@ -905,7 +905,7 @@
"ui.ai_always_allowed_none": "Zatím není nic trvale povoleno. Zde se zobrazí odpovědi „Vždy povolit“ zadané v chatu.",
"ui.ai_auto_allowed": "Automaticky povoleno: {title} ({why})",
"ui.ai_auto_mode": "vaše nastavení oprávnění",
"ui.ai_auto_ours": "SolidCraft své nástroje kontroluje sám",
"ui.ai_auto_ours": "OneCAD své nástroje kontroluje sám",
"ui.ai_auto_remembered": "zvolili jste Vždy povolit",
"ui.ai_bad_url": "Zadejte URL začínající http:// nebo https://",
"ui.ai_base_url": "Základní URL",
@@ -919,7 +919,7 @@
"ui.ai_key": "API klíč",
"ui.ai_key_hint": "volitelné; uloženo v systémové klíčence",
"ui.ai_key_needs_https": "Klíč se odesílá pouze přes HTTPS nebo na tento počítač.",
"ui.ai_key_note": "Ukládá se v systémové klíčence, nikdy v nastavení SolidCraftu. Pro místní server nechte prázdné.",
"ui.ai_key_note": "Ukládá se v systémové klíčence, nikdy v nastavení OneCADu. Pro místní server nechte prázdné.",
"ui.ai_keychain_error": "Systémová klíčenka odmítla: {error}",
"ui.ai_mode_all": "Povolit vše",
"ui.ai_mode_all_note": "Nic se nejdřív neptá. Hlášení o pádech zůstávají nepřístupná a limity složek stále platí.",
@@ -930,7 +930,7 @@
"ui.ai_mode_edits_note": "Modelovací příkazy běží bez ptaní; ukládání, otevírání, export a další souborové či destruktivní akce se stále ptají.",
"ui.ai_mode_global": "Podle nastavení výše",
"ui.ai_model": "Model",
"ui.ai_modes_scope": "Tato nastavení se týkají nástrojů SolidCraftu. Vlastní nástroje agenta (jeho shell, úpravy souborů) se řídí jeho vlastním režimem, který zvolíte v záhlaví panelu chatu, pokud agent režimy má.",
"ui.ai_modes_scope": "Tato nastavení se týkají nástrojů OneCADu. Vlastní nástroje agenta (jeho shell, úpravy souborů) se řídí jeho vlastním režimem, který zvolíte v záhlaví panelu chatu, pokud agent režimy má.",
"ui.ai_name": "Název",
"ui.ai_need_command": "Zadejte příkaz, který spouští agenta",
"ui.ai_need_model": "Zadejte název modelu",
@@ -1445,31 +1445,31 @@
"ui.zoom": "Přiblížení",
"ui.zoom_to_cursor_tip": "Kolečko přibližuje k bodu pod kurzorem (vypnuto: ke středu pohledu)",
"ui.zoom_to_the_cursor": "Přibližovat ke kurzoru",
"update.available": "Je k dispozici SolidCraft {version}",
"update.available": "Je k dispozici OneCAD {version}",
"update.button": "Aktualizovat",
"update.button_tip": "Je k dispozici SolidCraft {version}",
"update.button_tip": "Je k dispozici OneCAD {version}",
"update.button_version": "Aktualizovat na {version}",
"update.check_automatically": "Automaticky kontrolovat aktualizace",
"update.check_now": "Zkontrolovat nyní",
"update.checking": "Kontrola…",
"update.copy": "Kopírovat",
"update.dmg_opened": "Obraz disku je otevřený a ověřený: přetáhněte SolidCraft do Aplikací místo staré kopie a poté SolidCraft restartujte.",
"update.dmg_opened": "Obraz disku je otevřený a ověřený: přetáhněte OneCAD do Aplikací místo staré kopie a poté OneCAD restartujte.",
"update.download": "Stáhnout",
"update.download_open": "Stáhnout a otevřít",
"update.downloading": "Stahování a ověřování… {done} z {total} MB",
"update.failed": "Kontrola se nezdařila: {error}",
"update.file": "Stáhne se {file}.",
"update.install_failed": "Aktualizace se nenainstalovala: {error}. Nic se nezměnilo; tlačítkem Stáhnout získáte balíček.",
"update.install_msi": "Nainstalovat (zavře SolidCraft)",
"update.installed": "SolidCraft {version} je nainstalován. Pro použití restartujte.",
"update.installer_started": "Instalátor se spustí po zavření SolidCraftu.",
"update.install_msi": "Nainstalovat (zavře OneCAD)",
"update.installed": "OneCAD {version} je nainstalován. Pro použití restartujte.",
"update.installer_started": "Instalátor se spustí po zavření OneCADu.",
"update.later": "Připomenout později",
"update.no_notes": "Tato verze nemá poznámky.",
"update.package_hint": "Tato kopie pochází z {kind}. Stáhněte nový balíček a nainstalujte jej pomocí:",
"update.prefs_dev": "Toto je vývojová verze ({version}): aktualizace nekontroluje.",
"update.prefs_flatpak": "Tuto kopii ({version}) aktualizuje Flatpak: spusťte {command} nebo použijte správce softwaru. SolidCraft nekontroluje.",
"update.prefs_flatpak": "Tuto kopii ({version}) aktualizuje Flatpak: spusťte {command} nebo použijte správce softwaru. OneCAD nekontroluje.",
"update.prefs_label": "Aktualizace",
"update.prefs_note": "Toto je SolidCraft {version} ({kind}). Kontrola se dotáže GitHubu na nejnovější verzi a nic dalšího neodesílá.",
"update.prefs_note": "Toto je OneCAD {version} ({kind}). Kontrola se dotáže GitHubu na nejnovější verzi a nic dalšího neodesílá.",
"update.release_page": "Otevřít stránku vydání",
"update.restart": "Restartovat nyní",
"update.restart_later": "Později",
@@ -1477,7 +1477,7 @@
"update.skip": "Přeskočit tuto verzi",
"update.system_hint": "Tato kopie je {kind}. Aktualizujte ji pomocí:",
"update.title": "Je k dispozici aktualizace",
"update.up_to_date": "SolidCraft je aktuální.",
"update.up_to_date": "OneCAD je aktuální.",
"update.update_now": "Aktualizovat nyní",
"update.you_have": "Máte {version}."
}
+23 -23
View File
@@ -486,18 +486,18 @@
"home.report_issue": "Problem melden",
"home.samples": "Beispiele",
"home.whats_new": "Neuigkeiten",
"install.button_tip": "SolidCraft als App installieren: eigenes Fenster, funktioniert auch offline",
"install.button_tip": "OneCAD als App installieren: eigenes Fenster, 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.dock_after": "OneCAD öffnet sich dann aus dem Dock in einem eigenen Fenster und funktioniert auch offline.",
"install.done": "Fertig",
"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.home_screen_after": "OneCAD öffnet sich dann über sein Symbol auf dem Home-Bildschirm, ohne die Leisten von Safari, und funktioniert auch offline.",
"install.menu": "App installieren…",
"install.steps_intro": "Safari installiert Web-Apps über seine eigenen Menüs:",
"install.title": "SolidCraft installieren",
"install.title": "OneCAD installieren",
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist.",
"joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.",
"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.",
@@ -572,10 +572,10 @@
"settings.frame_rate_note": "Zeigt die Bilder pro Sekunde unten rechts in der Ansicht. Solange sie an ist, wird die Ansicht fortlaufend neu gezeichnet.",
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen",
"settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von OneCAD ü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",
"settings.reset_intro": "Diese Einstellungen funktionieren auf diesem Computer nicht und würden den Start von SolidCraft verhindern. Sie wurden daher geändert:",
"settings.reset_intro": "Diese Einstellungen funktionieren auf diesem Computer nicht und würden den Start von OneCAD verhindern. Sie wurden daher geändert:",
"settings.reset_title": "Einstellungen wurden geändert",
"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×.",
"sketch.degrees_of_freedom": "{count} Freiheitsgrade",
@@ -618,8 +618,8 @@
"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_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_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_env": "Setzen Sie {vars} in Ihrer Umgebung und starten Sie OneCAD neu.",
"ui.agent_chat_auth_needed": "Der Agent bittet Sie, sich anzumelden. Sie melden sich beim Agenten selbst an; OneCAD 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_retry": "Ich bin angemeldet: erneut verbinden",
"ui.agent_chat_auth_terminal": "Führen Sie dies in einem Terminal aus und klicken Sie dann erneut auf Verbinden:",
@@ -640,7 +640,7 @@
"ui.agent_chat_dock_right": "Rechts andocken",
"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_intro": "Chatten Sie mit einem Coding-Agenten, den Sie schon nutzen, oder einem Modell-Endpunkt Ihrer Wahl. Er arbeitet über die Werkzeuge von OneCAD 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_key_in_env": "Schlüssel gesetzt",
"ui.agent_chat_left": "Links",
"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.",
@@ -656,7 +656,7 @@
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_placeholder": "Bitten Sie den Agenten, das Design zu ändern…",
"ui.agent_chat_privacy": "Nichts geht irgendwohin außer an den Agenten, den Sie unter Ihrem eigenen Konto ausführen.",
"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_privacy_note": "OneCAD sendet nichts irgendwohin: Der Agent, den Sie starten, spricht unter Ihrem eigenen Konto mit seinem eigenen Dienst. OneCAD sieht und speichert nie einen Schlüssel oder ein Passwort.",
"ui.agent_chat_refresh": "Aktualisieren",
"ui.agent_chat_refused": "Für den Agenten nicht erlaubt: {reason}",
"ui.agent_chat_right": "Rechts",
@@ -686,7 +686,7 @@
"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_added": "hinzugefügt",
"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_agent_mode_tip": "Der eigene Modus des Agenten: Seine eigenen Werkzeuge (Shell, Dateiänderungen) folgen ihm. Die Werkzeuge von OneCAD folgen den Berechtigungen unter Einstellungen > KI.",
"ui.ai_agents": "Agenten",
"ui.ai_always_allow": "Immer erlauben",
"ui.ai_always_allow_tip": "{tool} für diesen Agenten ab jetzt ohne Nachfrage erlauben",
@@ -694,7 +694,7 @@
"ui.ai_always_allowed_none": "Noch nichts ist immer erlaubt. Antworten „Immer erlauben“ im Chat erscheinen hier.",
"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_ours": "OneCAD 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",
@@ -708,7 +708,7 @@
"ui.ai_key": "API-Schlüssel",
"ui.ai_key_hint": "optional; im Schlüsselbund des Systems gespeichert",
"ui.ai_key_needs_https": "Ein Schlüssel wird nur über HTTPS oder an diesen Computer gesendet.",
"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_key_note": "Im Schlüsselbund des Systems gespeichert, nie in den Einstellungen von OneCAD. Für einen lokalen Server leer lassen.",
"ui.ai_keychain_error": "Der Schlüsselbund des Systems hat abgelehnt: {error}",
"ui.ai_mode_all": "Alles erlauben",
"ui.ai_mode_all_note": "Nichts fragt vorher. Absturzberichte bleiben tabu, und die Ordnergrenzen gelten weiter.",
@@ -719,7 +719,7 @@
"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_modes_scope": "Diese Einstellungen gelten für die Werkzeuge von OneCAD. 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",
@@ -1206,31 +1206,31 @@
"ui.y_distance": "Y-Abstand",
"ui.zoom": "Zoomen",
"ui.zoom_to_the_cursor": "Zum Cursor zoomen",
"update.available": "SolidCraft {version} ist verfügbar",
"update.available": "OneCAD {version} ist verfügbar",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} ist verfügbar",
"update.button_tip": "OneCAD {version} ist verfügbar",
"update.button_version": "Auf {version} aktualisieren",
"update.check_automatically": "Automatisch nach Updates suchen",
"update.check_now": "Jetzt suchen",
"update.checking": "Suche läuft…",
"update.copy": "Kopieren",
"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.dmg_opened": "Das Disk-Image ist geöffnet und geprüft: OneCAD in „Programme“ ziehen, die alte Version ersetzen und OneCAD neu starten.",
"update.download": "Herunterladen",
"update.download_open": "Herunterladen und öffnen",
"update.downloading": "Herunterladen und prüfen… {done} von {total} MB",
"update.failed": "Suche fehlgeschlagen: {error}",
"update.file": "Herunterladen holt {file}.",
"update.install_failed": "Das Update wurde nicht installiert: {error}. Es wurde nichts verändert; „Herunterladen“ holt das Paket.",
"update.install_msi": "Installieren (schließt SolidCraft)",
"update.installed": "SolidCraft {version} ist installiert. Zum Verwenden neu starten.",
"update.installer_started": "Das Installationsprogramm startet, wenn SolidCraft geschlossen wird.",
"update.install_msi": "Installieren (schließt OneCAD)",
"update.installed": "OneCAD {version} ist installiert. Zum Verwenden neu starten.",
"update.installer_started": "Das Installationsprogramm startet, wenn OneCAD geschlossen wird.",
"update.later": "Später erinnern",
"update.no_notes": "Zu diesem Release gibt es keine Hinweise.",
"update.package_hint": "Diese Installation stammt aus einem {kind}. Neues Paket herunterladen und installieren mit:",
"update.prefs_dev": "Dies ist ein Entwicklungs-Build ({version}): er sucht nicht nach Updates.",
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. SolidCraft sucht nicht selbst.",
"update.prefs_flatpak": "Flatpak aktualisiert diese Installation ({version}): {command} ausführen oder die Softwareverwaltung nutzen. OneCAD sucht nicht selbst.",
"update.prefs_label": "Updates",
"update.prefs_note": "Dies ist SolidCraft {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
"update.prefs_note": "Dies ist OneCAD {version} ({kind}). Die Suche fragt GitHub nach dem neuesten Release und sendet sonst nichts.",
"update.release_page": "Release-Seite öffnen",
"update.restart": "Jetzt neu starten",
"update.restart_later": "Später",
@@ -1238,7 +1238,7 @@
"update.skip": "Diese Version überspringen",
"update.system_hint": "Diese Installation ist ein {kind}. Aktualisieren mit:",
"update.title": "Update verfügbar",
"update.up_to_date": "SolidCraft ist aktuell.",
"update.up_to_date": "OneCAD ist aktuell.",
"update.update_now": "Jetzt aktualisieren",
"update.you_have": "Installiert ist {version}."
}
+32 -25
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.",
"A new configuration, a copy of the active one": "A new configuration, a copy of the active one",
"AI": "AI",
"About SolidCraft": "About SolidCraft",
"About OneCAD": "About OneCAD",
"Acceleration": "Acceleration",
"Active": "Active",
"Active rule": "Active rule",
@@ -31,6 +31,7 @@
"Arc length": "Arc length",
"Area": "Area",
"Attachment": "Attachment",
"BOUNDARY SURFACE": "BOUNDARY SURFACE",
"BROWSER": "BROWSER",
"Bend Line Position": "Bend Line Position",
"Bend Position": "Bend Position",
@@ -73,6 +74,7 @@
"Counterbore depth": "Counterbore depth",
"Countersink angle": "Countersink angle",
"Create Issue — review draft": "Create Issue — review draft",
"Cross curves": "Cross curves",
"Cube": "Cube",
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
"Current perspective view": "Current perspective view",
@@ -252,7 +254,7 @@
"Sketch plane has no body-face attachment.": "Sketch plane has no body-face attachment.",
"Sketch points": "Sketch points",
"Slot width": "Slot width",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft closed before these designs were saved. Their last autosave can be opened.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD closed before these designs were saved. Their last autosave can be opened.",
"Speed": "Speed",
"Stand it up": "Stand it up",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Stand it up: a quarter turn about X, as one Move feature you can undo.",
@@ -695,18 +697,18 @@
"home.report_issue": "Report an Issue",
"home.samples": "Samples",
"home.whats_new": "What's new",
"install.button_tip": "Install SolidCraft as an app: it opens in its own window and works offline",
"install.button_tip": "Install OneCAD as an app: it opens in its own window 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.dock_after": "OneCAD then opens in its own window from the Dock, and works offline.",
"install.done": "Done",
"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.home_screen_after": "OneCAD then opens from its icon on the Home Screen, without Safari's toolbars, and works offline.",
"install.menu": "Install app…",
"install.steps_intro": "Safari installs web apps from its own menus:",
"install.title": "Install SolidCraft",
"install.title": "Install OneCAD",
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.",
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
"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.",
@@ -790,10 +792,10 @@
"settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner. While it is on, the view redraws continuously.",
"settings.measure_axis_distances": "Show X/Y/Z distances",
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts",
"settings.msaa_applies_on_restart": "Anti-aliasing applies the next time OneCAD 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",
"settings.reset_intro": "These settings don't work on this computer and would keep SolidCraft from starting, so they were changed:",
"settings.reset_intro": "These settings don't work on this computer and would keep OneCAD from starting, so they were changed:",
"settings.reset_title": "Settings were changed",
"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×.",
"sketch.degrees_of_freedom": "{count} degrees of freedom",
@@ -846,8 +848,8 @@
"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_auth_agent": "Sign in with {name}",
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
"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_env": "Set {vars} in your environment and restart OneCAD.",
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; OneCAD 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_retry": "I've signed in: connect again",
"ui.agent_chat_auth_terminal": "Run this in a terminal, then press Connect again:",
@@ -864,12 +866,13 @@
"ui.agent_chat_deny": "Deny",
"ui.agent_chat_desktop_only": "The agent chat needs the desktop app.",
"ui.agent_chat_detecting": "Looking for agents…",
"ui.agent_chat_dock": "Dock in the side panel",
"ui.agent_chat_dock_left": "Dock on the left",
"ui.agent_chat_dock_right": "Dock on the right",
"ui.agent_chat_edit": "Edit",
"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_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through OneCAD'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_key_in_env": "key set",
"ui.agent_chat_left": "Left",
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
@@ -885,12 +888,14 @@
"ui.agent_chat_none_found_short": "No agent found",
"ui.agent_chat_not_installed": "Not installed",
"ui.agent_chat_not_signed_in": "not signed in",
"ui.agent_chat_own_window": "Own window",
"ui.agent_chat_permission": "The agent asks to run: {title}",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_placeholder": "Ask the agent to change the design…",
"ui.agent_chat_placeholder_busy": "Queue a message for when the agent is done (Ctrl+Enter: send now)",
"ui.agent_chat_pop_out": "Open in its own window, which you can move to another screen",
"ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.",
"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_privacy_note": "OneCAD sends nothing anywhere: the agent you run talks to its own service under your own account. OneCAD never sees or stores a key or password.",
"ui.agent_chat_queue": "Queue",
"ui.agent_chat_queue_full": "Too many messages are waiting: remove one or wait for the agent.",
"ui.agent_chat_queued": "Queued",
@@ -929,7 +934,7 @@
"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_added": "added",
"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_agent_mode_tip": "The agent's own mode: its own tools (shell, file edits) follow it. OneCAD's tools follow the permissions in Preferences > AI.",
"ui.ai_agents": "Agents",
"ui.ai_always_allow": "Always allow",
"ui.ai_always_allow_tip": "Allow {tool} for this agent from now on, without asking",
@@ -937,7 +942,7 @@
"ui.ai_always_allowed_none": "Nothing is always allowed yet. \"Always allow\" answers you give in the chat are listed here.",
"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_ours": "OneCAD 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",
@@ -951,7 +956,7 @@
"ui.ai_key": "API key",
"ui.ai_key_hint": "optional; kept in the system keychain",
"ui.ai_key_needs_https": "A key is only sent over HTTPS, or to this computer.",
"ui.ai_key_note": "Stored in the system keychain, never in SolidCraft's settings. Leave it empty for a local server.",
"ui.ai_key_note": "Stored in the system keychain, never in OneCAD's settings. Leave it empty for a local server.",
"ui.ai_keychain_error": "The system keychain refused: {error}",
"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.",
@@ -962,7 +967,7 @@
"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_modes_scope": "These settings cover OneCAD'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",
@@ -1367,6 +1372,8 @@
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
"ui.restore": "Restore",
"ui.restore_defaults": "Restore Defaults",
"ui.reverse_horizontal_pan_direction": "Reverse horizontal pan direction",
"ui.reverse_horizontal_pan_tip": "Horizontal two-finger scrolling and the thumb wheel pan the other way",
"ui.reverse_zoom_direction": "Reverse zoom direction",
"ui.revolve": "Revolve",
"ui.right": "Right",
@@ -1490,31 +1497,31 @@
"ui.zoom": "Zoom",
"ui.zoom_to_cursor_tip": "The wheel zooms toward the point under the cursor (off: toward the view's centre)",
"ui.zoom_to_the_cursor": "Zoom to the cursor",
"update.available": "SolidCraft {version} is available",
"update.available": "OneCAD {version} is available",
"update.button": "Update",
"update.button_tip": "SolidCraft {version} is available",
"update.button_tip": "OneCAD {version} is available",
"update.button_version": "Update to {version}",
"update.check_automatically": "Check for updates automatically",
"update.check_now": "Check now",
"update.checking": "Checking…",
"update.copy": "Copy",
"update.dmg_opened": "The disk image is open and verified: drag SolidCraft to Applications, replacing the old copy, then restart SolidCraft.",
"update.dmg_opened": "The disk image is open and verified: drag OneCAD to Applications, replacing the old copy, then restart OneCAD.",
"update.download": "Download",
"update.download_open": "Download and open",
"update.downloading": "Downloading and verifying… {done} of {total} MB",
"update.failed": "Couldn't check: {error}",
"update.file": "Download gets {file}.",
"update.install_failed": "The update didn't install: {error}. Nothing was changed; Download gets the package instead.",
"update.install_msi": "Install (closes SolidCraft)",
"update.installed": "SolidCraft {version} is installed. Restart to use it.",
"update.installer_started": "The installer starts when SolidCraft closes.",
"update.install_msi": "Install (closes OneCAD)",
"update.installed": "OneCAD {version} is installed. Restart to use it.",
"update.installer_started": "The installer starts when OneCAD closes.",
"update.later": "Remind me later",
"update.no_notes": "This release has no notes.",
"update.package_hint": "This copy came from a {kind}. Download the new package, then install it with:",
"update.prefs_dev": "This is a development build ({version}): it doesn't check for updates.",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. SolidCraft doesn't check.",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software center. OneCAD doesn't check.",
"update.prefs_label": "Updates",
"update.prefs_note": "This is SolidCraft {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
"update.prefs_note": "This is OneCAD {version} ({kind}). A check asks GitHub for the latest release and sends nothing else.",
"update.release_page": "Open release page",
"update.restart": "Restart now",
"update.restart_later": "Later",
@@ -1522,7 +1529,7 @@
"update.skip": "Skip this version",
"update.system_hint": "This copy is a {kind}. Update it with:",
"update.title": "Update available",
"update.up_to_date": "SolidCraft is up to date.",
"update.up_to_date": "OneCAD is up to date.",
"update.update_now": "Update now",
"update.you_have": "You have {version}."
}
+99 -39
View File
@@ -6,7 +6,8 @@
"3. Click Submit new issue on GitHub to send your report.": "3. Haz clic en «Submit new issue» en GitHub para enviar el informe.",
"A new configuration, a copy of the active one": "Una configuración nueva, copia de la activa",
"AI": "AI",
"About SolidCraft": "Acerca de SolidCraft",
"About OneCAD": "Acerca de OneCAD",
"Acceleration": "Aceleración",
"Active": "Activo",
"Active rule": "Regla activa",
"Add": "Añadir",
@@ -14,8 +15,10 @@
"Add Mid Radius": "Añadir radio intermedio",
"Add Row": "Añadir fila",
"Add condition": "Añadir condición",
"Against": "Contra",
"Alignment": "Alineación",
"All Sketch Points": "Todos los puntos del boceto",
"All components": "Todos los componentes",
"All open designs": "Todos los diseños abiertos",
"Allowed expressions, separated by semicolons": "Expresiones permitidas, separadas por punto y coma",
"Also deletes": "También elimina",
@@ -57,6 +60,7 @@
"Configuration rules": "Reglas de configuración",
"Construction": "Construcción",
"Contact": "Contacto",
"Contact sets": "Conjuntos de contacto",
"Continuity": "Continuidad",
"Copy Error Message": "Copiar mensaje de error",
"Copy Warning Message": "Copiar mensaje de advertencia",
@@ -85,14 +89,18 @@
"Direction": "Dirección",
"Discard": "Descartar",
"Do not export hidden edges": "No exportar aristas ocultas",
"Don't ask again": "No volver a preguntar",
"Double-click to rename": "Doble clic para cambiar el nombre",
"Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Doble clic con el botón central en la vista: Inicio (vista inicial que encuadra lo visible), Ajustar (encuadrar sin cambiar la orientación) o nada",
"Drive Joint": "Accionar unión",
"Duration": "Duración",
"Edges": "Aristas",
"Edit Profile Sketch": "Editar boceto del perfil",
"Enable rule": "Activar regla",
"Enabled": "Activado",
"Explode": "Explosionar",
"Explode Text": "Descomponer texto",
"Export Animation…": "Exportar animación…",
"Export Current View as DXF": "Exportar vista actual como DXF",
"Export Current View as DXF…": "Exportar vista actual como DXF…",
"Export DXF…": "Exportar DXF…",
@@ -151,6 +159,8 @@
"Move earlier": "Mover antes",
"Move later": "Mover después",
"Name": "Nombre",
"Nearest": "Más cercano",
"New study": "Nuevo estudio",
"No crash reports.": "No hay informes de fallos.",
"No matching appearances": "No hay apariencias coincidentes",
"No visible bodies to export.": "No hay cuerpos visibles para exportar.",
@@ -170,16 +180,20 @@
"Per Edge": "Por arista",
"Pick the tool bodies": "Selecciona los cuerpos herramienta",
"Picked": "Seleccionado",
"Picked point": "Punto seleccionado",
"Place at the origin instead": "Colocar en el origen",
"Playback": "Reproducción",
"Plot": "Graficar",
"Position": "Posición",
"Position (0 to 1)": "Posición (de 0 a 1)",
"Position (G0)": "Posición (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Posición a lo largo de la arista: 0 en el extremo inicial y 1 en el otro",
"Preferences › Design turns the offer back on": "Preferencias › Diseño vuelve a activar la oferta",
"Preview Row": "Previsualizar fila",
"Preview leaves the current design unchanged.": "La vista previa no modifica el diseño actual.",
"Profiles/Curves": "Perfiles/curvas",
"Projection": "Proyección",
"Quantity": "Cantidad",
"RECENT": "RECIENTES",
"RULED SURFACE": "SUPERFICIE REGLADA",
"Radius Fit": "Ajuste de radio",
@@ -210,10 +224,11 @@
"Revert Position": "Revertir posición",
"Review the report and screenshot before opening GitHub.": "Revisa el informe y la captura antes de abrir GitHub.",
"Rim Edge": "Arista del borde",
"Roll History Marker Here": "Desplazar marcador del historial hasta aquí",
"Roll History Marker Here": "Desplazar marcador de la línea de tiempo hasta aquí",
"Round": "Redondo",
"Rule": "Regla",
"Save Rule": "Guardar regla",
"Save the study as an animated GIF, seen as the viewport shows it": "Guardar el estudio como GIF animado, tal como se ve en el visor",
"Saved preview. Geometry is being checked before editing.": "Vista previa guardada. Se está comprobando la geometría antes de editarla.",
"Scale factor": "Factor de escala",
"Search": "Buscar",
@@ -237,13 +252,14 @@
"Sketch plane has no body-face attachment.": "El plano del boceto no está vinculado a ninguna cara del cuerpo.",
"Sketch points": "Puntos del boceto",
"Slot width": "Anchura de la ranura",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft se cerró antes de guardar estos diseños. Puedes abrir su último guardado automático.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD se cerró antes de guardar estos diseños. Puedes abrir su último guardado automático.",
"Speed": "Velocidad",
"Stand it up": "Poner en vertical",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poner en vertical: un cuarto de vuelta alrededor del eje X, como una única operación Mover que puedes deshacer.",
"Stand this part up?": "¿Poner esta pieza en vertical?",
"Step": "Paso",
"Steps": "Pasos",
"Study": "Estudio",
"Style": "Estilo",
"Suppressed": "Suprimido",
"Swap Ends": "Intercambiar extremos",
@@ -263,6 +279,7 @@
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Esta cara no permite una vinculación persistente. Elige otra cara o utiliza expresamente un plano fijo.",
"To Offset": "Hasta desfase",
"Tolerance": "Tolerancia",
"Too close": "Demasiado cerca",
"Turn off section analysis before exporting the view.": "Desactiva el análisis de sección antes de exportar la vista.",
"Type": "Tipo",
"UNTRIM": "ANULAR RECORTE",
@@ -273,6 +290,7 @@
"Use selected face as fixed plane": "Usar la cara seleccionada como plano fijo",
"VIEW": "VISTA",
"Value": "Valor",
"Velocity": "Velocidad",
"Vertices": "Vértices",
"View navigator": "Navegador de vistas",
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Solo cuerpos visibles. Las curvas utilizan segmentos rectos (tolerancia de 0,01 mm).",
@@ -296,6 +314,13 @@
"browser.precision": "Precisión: {precision}",
"browser.sketch_name": "Boceto{number}",
"browser.units": "Unidades: {units}",
"cam.flat": "Cara plana",
"cam.follower": "Seguidor",
"cam.knife": "Arista de cuchilla",
"cam.needs_joints": "necesita una unión que gire y otra que deslice o bascule",
"cam.roller": "Rodillo",
"cam.roller_pick": "Rodillo (opcional)",
"cam.tip_pick": "Punta (opcional)",
"cmd.appearance_assign": "Apariencia",
"cmd.appearance_library": "Biblioteca de apariencias",
"cmd.appearance_list": "Listar apariencias",
@@ -388,6 +413,7 @@
"cmd.file_slicers": "Laminadores instalados",
"cmd.inspect_accessibility": "Análisis de accesibilidad",
"cmd.inspect_center_of_mass": "Centro de masas",
"cmd.inspect_clearance": "Holgura",
"cmd.inspect_compare": "Comparar cuerpos",
"cmd.inspect_continuity": "Análisis de continuidad",
"cmd.inspect_curvature_comb": "Análisis de peine de curvatura",
@@ -397,8 +423,10 @@
"cmd.inspect_interference": "Interferencia",
"cmd.inspect_isocurve": "Análisis de isocurvas",
"cmd.inspect_minimum_radius": "Análisis de radio mínimo",
"cmd.inspect_silhouette": "Silueta",
"cmd.inspect_zebra": "Análisis de franjas cebra",
"cmd.joint_as_built": "Unión según posición actual",
"cmd.joint_cam": "Leva",
"cmd.joint_create": "Unión",
"cmd.joint_delete": "Eliminar unión",
"cmd.joint_drive": "Accionar uniones",
@@ -429,8 +457,11 @@
"cmd.model_threads": "Listar roscas",
"cmd.motion_delete": "Eliminar estudio de movimiento",
"cmd.motion_export": "Exportar estudio de movimiento",
"cmd.motion_export_animation": "Exportar animación del movimiento",
"cmd.motion_interference": "Interferencia de movimiento",
"cmd.motion_list": "Listar estudios de movimiento",
"cmd.motion_play": "Reproducir estudio de movimiento",
"cmd.motion_plot": "Graficar estudio de movimiento",
"cmd.motion_study": "Estudio de movimiento",
"cmd.motion_trace": "Trayectoria de movimiento",
"cmd.motion_trace_keep": "Conservar trayectoria de movimiento",
@@ -456,7 +487,7 @@
"cmd.parts_library": "Piezas normalizadas",
"cmd.parts_resize": "Cambiar tamaño de pieza",
"cmd.plastic_assign_rule": "Asignar regla de plástico",
"cmd.plastic_boss": "Resalte",
"cmd.plastic_boss": "Saliente",
"cmd.plastic_lip": "Labio",
"cmd.plastic_manage_rules": "Gestionar reglas de plástico",
"cmd.plastic_rest": "Apoyo",
@@ -548,7 +579,7 @@
"cmd.sketch_slot_center_point": "Ranura por punto central",
"cmd.sketch_slot_center_to_center": "Ranura entre centros",
"cmd.sketch_slot_overall": "Ranura por longitud total",
"cmd.sketch_snap": "Detección de referencias",
"cmd.sketch_snap": "Referencia a objetos",
"cmd.sketch_solve": "Resolver boceto",
"cmd.sketch_spline3d": "Spline 3D",
"cmd.sketch_spline_control_point": "Spline por puntos de control",
@@ -573,7 +604,7 @@
"cmd.surface_extrude": "Extruir superficie",
"cmd.surface_fair": "Suavizar superficie",
"cmd.surface_fillet": "Redondeo de superficie",
"cmd.surface_loft": "Transición de superficies",
"cmd.surface_loft": "Solevación de superficies",
"cmd.surface_match": "Igualar superficie",
"cmd.surface_offset": "Desfasar superficie",
"cmd.surface_patch": "Parche",
@@ -595,7 +626,7 @@
"cmd.timeline_redefine": "Editar operación",
"cmd.timeline_rename": "Cambiar nombre",
"cmd.timeline_reorder": "Reordenar operación",
"cmd.timeline_roll_to": "Desplazar marcador del historial",
"cmd.timeline_roll_to": "Desplazar marcador de la línea de tiempo",
"cmd.timeline_rollback": "Mover marcador de la línea de tiempo",
"cmd.timeline_suppress": "Suprimir operación",
"cmd.view_apply": "Restaurar vista guardada",
@@ -634,6 +665,18 @@
"drive.stopped_by_contact": "Detenido en {value}: {a} atravesaría {b}",
"drive.stopped_by_loop": "Detenido en {value}: el bucle que pasa por {joint} no puede cerrarse más",
"drive.unconnected": "No mueve nada: {part} no está conectado a ningún elemento fijado",
"dxf.cancel_wait": "Esperando a que se detenga la exportación…",
"dxf.cancelled": "Exportación DXF cancelada",
"dxf.cancelling": "Cancelando la exportación DXF",
"dxf.exporting": "Exportando DXF",
"dxf.invalid_tolerance": "La tolerancia de curva debe estar entre 0,0001 y 1 mm.",
"dxf.progress.index": "Preparando superficies (2/5)",
"dxf.progress.meshes": "Preparando mallas (1/5)",
"dxf.progress.occlusion": "Resolviendo líneas ocultas (4/5)",
"dxf.progress.projection": "Proyectando geometría (3/5)",
"dxf.progress.write": "Escribiendo DXF (5/5)",
"dxf.tolerance": "Tolerancia de curva",
"dxf.tolerance_help": "Solo cuerpos visibles. Las curvas usan segmentos rectos. Una tolerancia menor da curvas más finas y consume más tiempo y memoria.",
"expression, e.g. 25 mm": "expresión, p. ej., 25 mm",
"fits the face, e.g. M8": "se ajusta a la cara, p. ej., M8",
"from the rule": "de la regla",
@@ -650,21 +693,25 @@
"home.report_issue": "Notificar un problema",
"home.samples": "Ejemplos",
"home.whats_new": "Novedades",
"install.button_tip": "Instalar SolidCraft como aplicación: se abre en una ventana propia y funciona sin conexión",
"install.button_tip": "Instalar OneCAD como aplicación: se abre en una ventana propia y funciona sin conexión",
"install.dock_1": "Elige Archivo › Añadir al Dock.",
"install.dock_2": "Haz clic en Añadir.",
"install.dock_after": "A partir de entonces, SolidCraft se abrirá desde el Dock en su propia ventana y funcionará sin conexión.",
"install.dock_after": "A partir de entonces, OneCAD se abrirá desde el Dock en su propia ventana y funcionará sin conexión.",
"install.done": "Listo",
"install.home_screen_1": "Toca Compartir (en el menú … si no aparece en la barra de herramientas).",
"install.home_screen_2": "Elige Añadir a la pantalla de inicio.",
"install.home_screen_3": "Toca Añadir.",
"install.home_screen_after": "SolidCraft se abrirá desde su icono en la pantalla de inicio, sin las barras de Safari, y funcionará sin conexión.",
"install.home_screen_after": "OneCAD se abrirá desde su icono en la pantalla de inicio, sin las barras de Safari, y funcionará sin conexión.",
"install.menu": "Instalar aplicación…",
"install.steps_intro": "Safari instala aplicaciones web desde sus propios menús:",
"install.title": "Instalar SolidCraft",
"install.title": "Instalar OneCAD",
"joint.from_bodies_hint": "Cada cuerpo seleccionado pasa a un componente nuevo propio y conserva su posición.",
"joint.one_body": "Las dos selecciones pertenecen a {body}. Selecciona la segunda en otra pieza.",
"joint.one_component": "Estas piezas son cuerpos de un mismo componente: {bodies}. Una unión mueve un componente respecto a otro, por lo que cada pieza debe tener su propio componente.",
"motion.checked_to": "Comprobado solo hasta el paso {step}: la comprobación agotó su presupuesto de trabajo",
"motion.collision": "{a} · {b}: pasos {steps}",
"motion.duration_hint": "segundos (vacío: por paso)",
"motion.no_collisions": "Sin colisiones durante el estudio",
"nav.touchpad.help": "Mantén pulsado Mayús y mueve el puntero para encuadrar; mantén Alt/Opción y muévelo para orbitar. No hace falta pulsar ningún botón del ratón. El desplazamiento vertical hace zoom.",
"nav.touchpad.label": "Panel táctil",
"nav.touchpad.orbit": "Orbitar (mantén Alt/Opción y mueve el puntero; sin pulsar botones)",
@@ -727,24 +774,24 @@
"render.use_preview": "Usar vista previa integrada",
"sample.bracket.about": "Brida base con pestañas laterales y reborde, según una regla de 2 mm",
"sample.bracket.name": "Soporte de chapa",
"sample.enclosure.about": "Caja vaciada con labio, alojamientos para tornillos, apoyo y cierre a presión",
"sample.enclosure.about": "Caja vaciada con labio, salientes para tornillos, apoyo y encaje a presión",
"sample.enclosure.name": "Carcasa de plástico",
"sample.flange.about": "Cubo y brida con patrón circular controlado por un parámetro",
"sample.flange.name": "Brida atornillada",
"sample.hinge.about": "Dos componentes y una unión de revolución accionada hasta 60°",
"sample.hinge.name": "Conjunto de bisagra",
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un resalte perforado",
"sample.plate.about": "Placa paramétrica con redondeos, agujeros y un saliente perforado",
"sample.plate.name": "Placa de ejemplo",
"sample.vents.about": "Ranuras en un patrón rectangular: las filas, las columnas y el paso son parámetros",
"sample.vents.name": "Placa de ventilación",
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia la vista, nunca el diseño ni las exportaciones.",
"settings.display_quality_note": "Los niveles más finos muestran las caras y aristas curvas con mayor suavidad, pero tardan más en generar la malla. Solo cambia el visor, nunca el diseño ni las exportaciones.",
"settings.frame_rate_note": "Muestra los fotogramas por segundo en la esquina inferior derecha de la vista. Mientras esté activado, la vista se redibuja continuamente.",
"settings.measure_axis_distances": "Mostrar distancias X/Y/Z",
"settings.measure_axis_distances_help": "Mostrar las cotas de cada eje global junto a la distancia total mientras está abierta la herramienta Medir.",
"settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie SolidCraft",
"settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie OneCAD",
"settings.msaa_reset": "{setting}: este dispositivo gráfico no admite {requested}×; se ha establecido {reset}× (admitidos: {supported}).",
"settings.msaa_unsupported": "No compatible con este dispositivo gráfico",
"settings.reset_intro": "Estos ajustes no funcionan en este ordenador e impedirían iniciar SolidCraft, por lo que se han cambiado:",
"settings.reset_intro": "Estos ajustes no funcionan en este ordenador e impedirían iniciar OneCAD, por lo que se han cambiado:",
"settings.reset_title": "Se han cambiado los ajustes",
"settings.supersampling_note": "Dibuja la vista a mayor resolución y después la reduce: bordes, líneas y cuadrícula más suaves en pantallas inferiores a 4K. Consume recursos de GPU: unos 2,25× píxeles a 1,5× y 4× a 2×.",
"sketch.degrees_of_freedom": "{count} grados de libertad",
@@ -763,6 +810,10 @@
"sketch_text.path_hint": "Curva del boceto que seguirá el texto: haz clic aquí y después en la curva",
"sketch_text.use_font_file": "Usar el archivo de fuente {path}",
"the parts library is not available": "la biblioteca de piezas no está disponible",
"thread.automatic_size": "Tamaño automático",
"thread.designation": "Designación",
"thread.iso_metric": "Métrica ISO",
"thread.select_size": "Seleccionar tamaño",
"timeline.group": "Nueva carpeta de operaciones",
"timeline.move_many": "Mover elementos de la línea de tiempo",
"timeline.move_to_group": "Mover operaciones a la carpeta",
@@ -791,8 +842,8 @@
"ui.agent_chat_attach_view": "Adjuntar vista",
"ui.agent_chat_attach_view_tip": "Enviar una imagen de la vista actual con el mensaje",
"ui.agent_chat_auth_agent": "Iniciar sesión con {name}",
"ui.agent_chat_auth_env": "Configura {vars} en las variables de entorno y reinicia SolidCraft.",
"ui.agent_chat_auth_needed": "El agente te pide que inicies sesión. El inicio de sesión se realiza con el propio agente; SolidCraft nunca ve tu contraseña ni tus claves.",
"ui.agent_chat_auth_env": "Configura {vars} en las variables de entorno y reinicia OneCAD.",
"ui.agent_chat_auth_needed": "El agente te pide que inicies sesión. El inicio de sesión se realiza con el propio agente; OneCAD nunca ve tu contraseña ni tus claves.",
"ui.agent_chat_auth_none": "El agente no ofrece aquí ningún método de inicio de sesión: sigue las instrucciones de su documentación.",
"ui.agent_chat_auth_retry": "Ya he iniciado sesión: volver a conectar",
"ui.agent_chat_auth_terminal": "Ejecuta esto en una terminal y vuelve a pulsar Conectar:",
@@ -814,7 +865,7 @@
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_exited": "El agente se ha detenido.",
"ui.agent_chat_failed_start": "No se ha podido iniciar el agente: {error}",
"ui.agent_chat_intro": "Habla con un agente de programación que ya utilices o con un servicio de modelos que elijas. Trabaja sobre el diseño abierto mediante las herramientas de SolidCraft: sus cambios se muestran al realizarlos y pueden deshacerse de una vez por mensaje. Lo que puede hacer sin preguntar se configura en Preferencias > IA.",
"ui.agent_chat_intro": "Habla con un agente de programación que ya utilices o con un servicio de modelos que elijas. Trabaja sobre el diseño abierto mediante las herramientas de OneCAD: sus cambios se muestran al realizarlos y pueden deshacerse de una vez por mensaje. Lo que puede hacer sin preguntar se configura en Preferencias > IA.",
"ui.agent_chat_key_in_env": "clave configurada",
"ui.agent_chat_left": "Izquierda",
"ui.agent_chat_limit": "El agente ha alcanzado el límite de {limit} cambios por mensaje. Envía otro mensaje para que continúe.",
@@ -835,7 +886,7 @@
"ui.agent_chat_placeholder": "Pide al agente que modifique el diseño…",
"ui.agent_chat_placeholder_busy": "Pon un mensaje en cola para cuando termine el agente (Ctrl+Intro: enviar ahora)",
"ui.agent_chat_privacy": "Solo se envían datos al agente que ejecutes, con tu propia cuenta.",
"ui.agent_chat_privacy_note": "SolidCraft no envía nada a otros destinos: el agente que ejecutas se comunica con su propio servicio utilizando tu cuenta. SolidCraft nunca ve ni almacena claves o contraseñas.",
"ui.agent_chat_privacy_note": "OneCAD no envía nada a otros destinos: el agente que ejecutas se comunica con su propio servicio utilizando tu cuenta. OneCAD nunca ve ni almacena claves o contraseñas.",
"ui.agent_chat_queue": "Poner en cola",
"ui.agent_chat_queue_full": "Hay demasiados mensajes en espera: elimina uno o espera a que termine el agente.",
"ui.agent_chat_queued": "En cola",
@@ -874,7 +925,7 @@
"ui.ai_add_installed": "Agente instalado",
"ui.ai_add_installed_note": "Una entrada personalizada para un agente instalado, por ejemplo, con otros argumentos. Los agentes instalados también aparecen por separado.",
"ui.ai_added": "añadido",
"ui.ai_agent_mode_tip": "Modo propio del agente: se aplica a sus herramientas (terminal, edición de archivos). Las herramientas de SolidCraft siguen los permisos de Preferencias > IA.",
"ui.ai_agent_mode_tip": "Modo propio del agente: se aplica a sus herramientas (terminal, edición de archivos). Las herramientas de OneCAD siguen los permisos de Preferencias > IA.",
"ui.ai_agents": "Agentes",
"ui.ai_always_allow": "Permitir siempre",
"ui.ai_always_allow_tip": "Permitir {tool} a este agente a partir de ahora, sin preguntar",
@@ -882,7 +933,7 @@
"ui.ai_always_allowed_none": "Todavía no hay permisos permanentes. Aquí se muestran las respuestas «Permitir siempre» que hayas dado en el chat.",
"ui.ai_auto_allowed": "Permitido automáticamente: {title} ({why})",
"ui.ai_auto_mode": "tu configuración de permisos",
"ui.ai_auto_ours": "SolidCraft comprueba sus propias herramientas",
"ui.ai_auto_ours": "OneCAD comprueba sus propias herramientas",
"ui.ai_auto_remembered": "elegiste Permitir siempre",
"ui.ai_bad_url": "Introduce una URL que empiece por http:// o https://",
"ui.ai_base_url": "URL base",
@@ -896,7 +947,7 @@
"ui.ai_key": "Clave de API",
"ui.ai_key_hint": "opcional; se guarda en el llavero del sistema",
"ui.ai_key_needs_https": "La clave solo se envía mediante HTTPS o a este ordenador.",
"ui.ai_key_note": "Se guarda en el llavero del sistema, nunca en los ajustes de SolidCraft. Déjalo vacío para un servidor local.",
"ui.ai_key_note": "Se guarda en el llavero del sistema, nunca en los ajustes de OneCAD. Déjalo vacío para un servidor local.",
"ui.ai_keychain_error": "El llavero del sistema ha rechazado la operación: {error}",
"ui.ai_mode_all": "Permitir todo",
"ui.ai_mode_all_note": "No se pide permiso previamente. Los informes de fallos siguen estando restringidos y se mantienen los límites de carpetas.",
@@ -907,7 +958,7 @@
"ui.ai_mode_edits_note": "Los comandos de modelado se ejecutan sin preguntar; guardar, abrir, exportar y otras acciones sobre archivos o destructivas siguen requiriendo permiso.",
"ui.ai_mode_global": "Según la configuración anterior",
"ui.ai_model": "Modelo",
"ui.ai_modes_scope": "Estos ajustes se aplican a las herramientas de SolidCraft. Las herramientas propias del agente (terminal, edición de archivos) siguen el modo del agente, que puedes elegir en la cabecera del panel de chat si dispone de modos.",
"ui.ai_modes_scope": "Estos ajustes se aplican a las herramientas de OneCAD. Las herramientas propias del agente (terminal, edición de archivos) siguen el modo del agente, que puedes elegir en la cabecera del panel de chat si dispone de modos.",
"ui.ai_name": "Nombre",
"ui.ai_need_command": "Introduce el comando que inicia el agente",
"ui.ai_need_model": "Introduce el nombre del modelo",
@@ -1008,6 +1059,9 @@
"ui.construct": "Construir",
"ui.construction": "Construcción",
"ui.context_menu": "Menú contextual",
"ui.continuity_g0": "G0 arista",
"ui.continuity_g1": "G1 tangencia",
"ui.continuity_g2": "G2 curvatura",
"ui.copy": "Copiar",
"ui.corner": "Esquina",
"ui.corners": "Esquinas",
@@ -1069,7 +1123,7 @@
"ui.equivalent_radius": "Radio equivalente",
"ui.expand": "Expandir",
"ui.exportDxf": "Exportar DXF…",
"ui.export_ellipsis": "Exportar...",
"ui.export_ellipsis": "Exportar…",
"ui.export_ellipsis_keymap": "Exportar…",
"ui.export_keymap_tip": "Exportar las teclas, los favoritos de la búsqueda S y la barra de herramientas como archivo",
"ui.extend": "Extender",
@@ -1095,6 +1149,8 @@
"ui.fully_constrained": "Totalmente restringido",
"ui.general": "General",
"ui.graphics": "Gráficos",
"ui.grid_follows_view": "La cuadrícula sigue a la vista",
"ui.grid_follows_view_hint": "Las vistas laterales muestran la cuadrícula en el plano vertical que tienen enfrente, en lugar del suelo",
"ui.ground_shadow": "Sombra en el suelo",
"ui.groupSelected": "Agrupar selección",
"ui.heading_add_ins": "COMPLEMENTOS",
@@ -1121,7 +1177,7 @@
"ui.heading_home": "INICIO",
"ui.heading_insert": "INSERTAR",
"ui.heading_inspect": "INSPECCIONAR",
"ui.heading_loft": "TRANSICIÓN",
"ui.heading_loft": "SOLEVACIÓN",
"ui.heading_make": "FABRICAR",
"ui.heading_measure": "MEDIR",
"ui.heading_mesh": "MALLA",
@@ -1191,7 +1247,7 @@
"ui.line": "Línea",
"ui.linetype": "Tipo de línea",
"ui.lock": "Bloquear",
"ui.loft": "Transición",
"ui.loft": "Solevación",
"ui.look_at": "Mirar hacia",
"ui.look_at_anim_desc": "Vista de eje, Mirar hacia, Inicio y Ajustar",
"ui.manufacture": "Fabricación",
@@ -1244,7 +1300,7 @@
"ui.offset_plane": "Plano desfasado",
"ui.one_side": "Un lado",
"ui.open": "Abrir",
"ui.open_ellipsis": "Abrir...",
"ui.open_ellipsis": "Abrir…",
"ui.open_preferences": "Abrir preferencias",
"ui.open_recent": "Abrir reciente",
"ui.operation": "Operación",
@@ -1289,6 +1345,8 @@
"ui.radial_context_menu": "Menú contextual radial",
"ui.radius": "Radio",
"ui.radius_type": "Tipo de radio",
"ui.rail": "Línea guía",
"ui.rail_fit": "Ajuste a la guía",
"ui.reachable": "Alcanzable",
"ui.rectangle": "Rectángulo",
"ui.rectangular_pattern": "Patrón rectangular",
@@ -1309,12 +1367,13 @@
"ui.right": "Derecha",
"ui.save": "Guardar",
"ui.save_as": "Guardar como",
"ui.save_as_ellipsis": "Guardar como...",
"ui.save_as_ellipsis": "Guardar como…",
"ui.save_mesh_no_slicer": "No se ha encontrado ningún laminador: elige Personalizado… y selecciona tu laminador, o desactiva Enviar al laminador",
"ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar",
"ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}",
"ui.saved_to": "Guardado en {path}",
"ui.scale": "Escala",
"ui.second_rail": "Segunda línea guía",
"ui.section_analysis": "Análisis de sección",
"ui.select": "Seleccionar",
"ui.selectOther": "Seleccionar otro…",
@@ -1354,7 +1413,7 @@
"ui.smooth": "Suave",
"ui.smooth_wheel_zoom": "Zoom suave con la rueda",
"ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior",
"ui.snap": "Ajustar a referencias",
"ui.snap": "Referencia a objetos",
"ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.",
"ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse",
"ui.spacing": "Espaciado",
@@ -1365,6 +1424,7 @@
"ui.stand_up_tip": "Tras abrir un archivo STEP o IGES con Z hacia arriba, ofrecer un cuarto de vuelta alrededor de X",
"ui.start": "Iniciar",
"ui.start_radius": "Radio inicial",
"ui.stretch": "Estirar",
"ui.supersampling": "Supermuestreo (SSAA)",
"ui.supersampling_adaptive": "Solo cuando la vista está inmóvil",
"ui.supersampling_off": "Desactivado (1×)",
@@ -1422,31 +1482,31 @@
"ui.zoom": "Zoom",
"ui.zoom_to_cursor_tip": "La rueda hace zoom hacia el punto bajo el cursor (desactivado: hacia el centro de la vista)",
"ui.zoom_to_the_cursor": "Zoom hacia el cursor",
"update.available": "SolidCraft {version} está disponible",
"update.available": "OneCAD {version} está disponible",
"update.button": "Actualizar",
"update.button_tip": "SolidCraft {version} está disponible",
"update.button_tip": "OneCAD {version} está disponible",
"update.button_version": "Actualizar a {version}",
"update.check_automatically": "Buscar actualizaciones automáticamente",
"update.check_now": "Comprobar ahora",
"update.checking": "Comprobando…",
"update.copy": "Copiar",
"update.dmg_opened": "La imagen de disco está abierta y verificada: arrastra SolidCraft a Aplicaciones, sustituye la copia anterior y reinicia SolidCraft.",
"update.dmg_opened": "La imagen de disco está abierta y verificada: arrastra OneCAD a Aplicaciones, sustituye la copia anterior y reinicia OneCAD.",
"update.download": "Descargar",
"update.download_open": "Descargar y abrir",
"update.downloading": "Descargando y verificando… {done} de {total} MB",
"update.failed": "No se ha podido comprobar: {error}",
"update.file": "La descarga obtiene {file}.",
"update.install_failed": "No se ha podido instalar la actualización: {error}. No se ha modificado nada; la opción Descargar obtiene el paquete.",
"update.install_msi": "Instalar (cierra SolidCraft)",
"update.installed": "SolidCraft {version} está instalado. Reinicia para utilizarlo.",
"update.installer_started": "El instalador se iniciará cuando se cierre SolidCraft.",
"update.install_msi": "Instalar (cierra OneCAD)",
"update.installed": "OneCAD {version} está instalado. Reinicia para utilizarlo.",
"update.installer_started": "El instalador se iniciará cuando se cierre OneCAD.",
"update.later": "Recordármelo más tarde",
"update.no_notes": "Esta versión no tiene notas.",
"update.package_hint": "Esta copia procede de {kind}. Descarga el paquete nuevo e instálalo con:",
"update.prefs_dev": "Esta es una compilación de desarrollo ({version}): no busca actualizaciones.",
"update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): ejecuta {command} o utiliza el centro de software. SolidCraft no lo comprueba.",
"update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): ejecuta {command} o utiliza el centro de software. OneCAD no lo comprueba.",
"update.prefs_label": "Actualizaciones",
"update.prefs_note": "Esta es la versión {version} de SolidCraft ({kind}). La comprobación consulta en GitHub la última versión y no envía ningún otro dato.",
"update.prefs_note": "Esta es la versión {version} de OneCAD ({kind}). La comprobación consulta en GitHub la última versión y no envía ningún otro dato.",
"update.release_page": "Abrir página de la versión",
"update.restart": "Reiniciar ahora",
"update.restart_later": "Más tarde",
@@ -1454,7 +1514,7 @@
"update.skip": "Omitir esta versión",
"update.system_hint": "Esta copia es de tipo {kind}. Actualízala con:",
"update.title": "Actualización disponible",
"update.up_to_date": "SolidCraft está actualizado.",
"update.up_to_date": "OneCAD está actualizado.",
"update.update_now": "Actualizar ahora",
"update.you_have": "Tienes la versión {version}."
}
+35 -25
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. Cliquer sur Submit new issue sur GitHub pour envoyer votre rapport.",
"A new configuration, a copy of the active one": "Nouvelle configuration, copie de la configuration active",
"AI": "IA",
"About SolidCraft": "À propos de SolidCraft",
"About OneCAD": "À propos de OneCAD",
"Acceleration": "Accélération",
"Active": "Actif",
"Active rule": "Règle active",
@@ -99,6 +99,7 @@
"Enable rule": "Activer la règle",
"Enabled": "Activé",
"Explode": "Éclater",
"Explode Text": "Décomposer le texte",
"Export Animation…": "Exporter l'animation…",
"Export Current View as DXF": "Exporter la vue actuelle en DXF",
"Export Current View as DXF…": "Exporter la vue actuelle en DXF…",
@@ -158,6 +159,7 @@
"Move earlier": "Déplacer plus tôt",
"Move later": "Déplacer plus tard",
"Name": "Nom",
"Nearest": "Corps le plus proche",
"New study": "Nouvelle étude",
"No crash reports.": "Aucun rapport de plantage.",
"No matching appearances": "Aucune apparence correspondante",
@@ -250,7 +252,7 @@
"Sketch plane has no body-face attachment.": "Le plan d'esquisse n'est rattaché à aucune face de corps.",
"Sketch points": "Points d'esquisse",
"Slot width": "Largeur de rainure",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft s'est fermé avant l'enregistrement de ces conceptions. Leur dernier enregistrement automatique peut être ouvert.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD s'est fermé avant l'enregistrement de ces conceptions. Leur dernier enregistrement automatique peut être ouvert.",
"Speed": "Vitesse",
"Stand it up": "Redresser",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Redresser : un quart de tour autour de X, sous forme d'une fonction Déplacer que vous pouvez annuler.",
@@ -277,6 +279,7 @@
"This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.": "Cette face ne peut pas fournir de rattachement durable. Choisissez une autre face ou utilisez explicitement un plan fixe.",
"To Offset": "Jusqu'au décalage",
"Tolerance": "Tolérance",
"Too close": "Trop proches",
"Turn off section analysis before exporting the view.": "Désactivez l'analyse de coupe avant d'exporter la vue.",
"Type": "Type",
"UNTRIM": "ANNULER L'AJUSTEMENT",
@@ -410,6 +413,7 @@
"cmd.file_slicers": "Slicers installés",
"cmd.inspect_accessibility": "Analyse d'accessibilité",
"cmd.inspect_center_of_mass": "Centre de gravité",
"cmd.inspect_clearance": "Jeu",
"cmd.inspect_compare": "Comparer les corps",
"cmd.inspect_continuity": "Analyse de continuité",
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
@@ -419,6 +423,7 @@
"cmd.inspect_interference": "Interférence",
"cmd.inspect_isocurve": "Analyse des isocourbes",
"cmd.inspect_minimum_radius": "Analyse du rayon minimal",
"cmd.inspect_silhouette": "Silhouette",
"cmd.inspect_zebra": "Analyse zébrée",
"cmd.joint_as_built": "Liaison telle que construite",
"cmd.joint_cam": "Came",
@@ -688,18 +693,18 @@
"home.report_issue": "Signaler un problème",
"home.samples": "Exemples",
"home.whats_new": "Nouveautés",
"install.button_tip": "Installer SolidCraft comme une app : il s'ouvre dans sa propre fenêtre et fonctionne hors ligne",
"install.button_tip": "Installer OneCAD comme une app : il s'ouvre dans sa propre fenêtre et fonctionne hors ligne",
"install.dock_1": "Choisissez Fichier › Ajouter au Dock.",
"install.dock_2": "Cliquez sur Ajouter.",
"install.dock_after": "SolidCraft s'ouvre ensuite dans sa propre fenêtre depuis le Dock, et fonctionne hors ligne.",
"install.dock_after": "OneCAD s'ouvre ensuite dans sa propre fenêtre depuis le Dock, et fonctionne hors ligne.",
"install.done": "Terminé",
"install.home_screen_1": "Touchez Partager (dans le menu … s'il n'est pas dans la barre d'outils).",
"install.home_screen_2": "Choisissez Sur l'écran d'accueil.",
"install.home_screen_3": "Touchez Ajouter.",
"install.home_screen_after": "SolidCraft s'ouvre ensuite depuis son icône sur l'écran d'accueil, sans les barres de Safari, et fonctionne hors ligne.",
"install.home_screen_after": "OneCAD s'ouvre ensuite depuis son icône sur l'écran d'accueil, sans les barres de Safari, et fonctionne hors ligne.",
"install.menu": "Installer l'app…",
"install.steps_intro": "Safari installe les apps web depuis ses propres menus :",
"install.title": "Installer SolidCraft",
"install.title": "Installer OneCAD",
"joint.from_bodies_hint": "Chaque corps sélectionné passe dans un nouveau composant à lui et reste où il est.",
"joint.one_body": "Les deux sélections sont sur {body}. Choisissez la seconde sur une autre pièce.",
"joint.one_component": "Ces pièces sont des corps d'un même composant : {bodies}. Une liaison déplace un composant par rapport à un autre, chaque pièce a donc besoin de son propre composant.",
@@ -783,10 +788,10 @@
"settings.frame_rate_note": "Affiche les images par seconde dans le coin inférieur droit de la vue. Tant qu'elle est activée, la vue est redessinée en continu.",
"settings.measure_axis_distances": "Afficher les cotes X/Y/Z",
"settings.measure_axis_distances_help": "Afficher les cotes suivant les axes du repère global en plus de la distance totale pendant la mesure.",
"settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft",
"settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de OneCAD",
"settings.msaa_reset": "{setting} : {requested}× n'est pas pris en charge par ce périphérique graphique ; la valeur est désormais {reset}× (pris en charge : {supported}).",
"settings.msaa_unsupported": "Non pris en charge par ce périphérique graphique",
"settings.reset_intro": "Ces réglages ne fonctionnent pas sur cet ordinateur et empêcheraient SolidCraft de démarrer ; ils ont donc été modifiés :",
"settings.reset_intro": "Ces réglages ne fonctionnent pas sur cet ordinateur et empêcheraient OneCAD de démarrer ; ils ont donc été modifiés :",
"settings.reset_title": "Des réglages ont été modifiés",
"settings.supersampling_note": "Dessine la vue à une résolution plus élevée puis la réduit : arêtes, lignes et grille plus lisses sur les écrans de définition inférieure à la 4K. Cela coûte du temps GPU : environ 2,25× plus de pixels à 1,5× et 4× plus à 2×.",
"sketch.degrees_of_freedom": "{count} degrés de liberté",
@@ -837,8 +842,8 @@
"ui.agent_chat_attach_view": "Joindre la vue",
"ui.agent_chat_attach_view_tip": "Envoyer une image de la vue actuelle avec le message",
"ui.agent_chat_auth_agent": "S'authentifier avec {name}",
"ui.agent_chat_auth_env": "Définir {vars} dans votre environnement, puis redémarrer SolidCraft.",
"ui.agent_chat_auth_needed": "L'agent demande une authentification. Vous vous authentifiez auprès de l'agent lui-même ; SolidCraft ne voit jamais votre mot de passe ni vos clés.",
"ui.agent_chat_auth_env": "Définir {vars} dans votre environnement, puis redémarrer OneCAD.",
"ui.agent_chat_auth_needed": "L'agent demande une authentification. Vous vous authentifiez auprès de l'agent lui-même ; OneCAD ne voit jamais votre mot de passe ni vos clés.",
"ui.agent_chat_auth_none": "L'agent ne propose ici aucun moyen d'authentification : s'authentifier comme le décrit sa propre documentation.",
"ui.agent_chat_auth_retry": "Je suis authentifié : reconnecter",
"ui.agent_chat_auth_terminal": "Exécuter ceci dans un terminal, puis appuyer de nouveau sur Connecter :",
@@ -860,7 +865,7 @@
"ui.agent_chat_edit": "Modifier",
"ui.agent_chat_exited": "L'agent s'est arrêté.",
"ui.agent_chat_failed_start": "L'agent n'a pas pu démarrer : {error}",
"ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de SolidCraft : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.",
"ui.agent_chat_intro": "Discuter avec un agent de programmation que vous utilisez déjà, ou avec un point d'accès de modèle de votre choix. Il travaille sur la conception ouverte avec les outils de OneCAD : ses modifications apparaissent au fur et à mesure et s'annulent en une seule étape par message. Ce qu'il peut faire sans demander se règle dans Préférences › IA.",
"ui.agent_chat_key_in_env": "clé définie",
"ui.agent_chat_left": "Gauche",
"ui.agent_chat_limit": "L'agent a atteint la limite de {limit} modifications pour un message. Envoyez un autre message pour qu'il poursuive.",
@@ -881,7 +886,7 @@
"ui.agent_chat_placeholder": "Demander à l'agent de modifier la conception…",
"ui.agent_chat_placeholder_busy": "Mettre un message en file d'attente pour quand l'agent aura terminé (Ctrl+Enter : envoyer maintenant)",
"ui.agent_chat_privacy": "Rien n'est envoyé ailleurs qu'à l'agent que vous lancez, sous votre propre compte.",
"ui.agent_chat_privacy_note": "SolidCraft n'envoie rien nulle part : l'agent que vous lancez dialogue avec son propre service, sous votre propre compte. SolidCraft ne voit ni ne stocke jamais de clé ni de mot de passe.",
"ui.agent_chat_privacy_note": "OneCAD n'envoie rien nulle part : l'agent que vous lancez dialogue avec son propre service, sous votre propre compte. OneCAD ne voit ni ne stocke jamais de clé ni de mot de passe.",
"ui.agent_chat_queue": "Mettre en file",
"ui.agent_chat_queue_full": "Trop de messages sont en attente : retirez-en un ou attendez l'agent.",
"ui.agent_chat_queued": "En file d'attente",
@@ -920,7 +925,7 @@
"ui.ai_add_installed": "Agent installé",
"ui.ai_add_installed_note": "Une entrée à vous pour un agent installé, par exemple avec d'autres arguments. Les agents installés sont listés automatiquement.",
"ui.ai_added": "ajouté",
"ui.ai_agent_mode_tip": "Le mode propre à l'agent : ses propres outils (shell, modifications de fichiers) le suivent. Les outils de SolidCraft suivent les autorisations de Préférences › IA.",
"ui.ai_agent_mode_tip": "Le mode propre à l'agent : ses propres outils (shell, modifications de fichiers) le suivent. Les outils de OneCAD suivent les autorisations de Préférences › IA.",
"ui.ai_agents": "Agents",
"ui.ai_always_allow": "Toujours autoriser",
"ui.ai_always_allow_tip": "Autoriser {tool} pour cet agent dorénavant, sans demander",
@@ -928,7 +933,7 @@
"ui.ai_always_allowed_none": "Rien n'est encore toujours autorisé. Les réponses « Toujours autoriser » données dans le chat sont listées ici.",
"ui.ai_auto_allowed": "Autorisé automatiquement : {title} ({why})",
"ui.ai_auto_mode": "votre réglage des autorisations",
"ui.ai_auto_ours": "SolidCraft vérifie lui-même ses propres outils",
"ui.ai_auto_ours": "OneCAD vérifie lui-même ses propres outils",
"ui.ai_auto_remembered": "vous avez choisi Toujours autoriser",
"ui.ai_bad_url": "Saisir une URL commençant par http:// ou https://",
"ui.ai_base_url": "URL de base",
@@ -942,7 +947,7 @@
"ui.ai_key": "Clé d'API",
"ui.ai_key_hint": "facultative ; conservée dans le trousseau de clés du système",
"ui.ai_key_needs_https": "Une clé n'est envoyée qu'en HTTPS, ou vers cet ordinateur.",
"ui.ai_key_note": "Stockée dans le trousseau de clés du système, jamais dans les réglages de SolidCraft. Laisser vide pour un serveur local.",
"ui.ai_key_note": "Stockée dans le trousseau de clés du système, jamais dans les réglages de OneCAD. Laisser vide pour un serveur local.",
"ui.ai_keychain_error": "Le trousseau de clés du système a refusé : {error}",
"ui.ai_mode_all": "Tout autoriser",
"ui.ai_mode_all_note": "Rien ne demande d'abord. Les rapports de plantage restent inaccessibles et les limites de dossiers s'appliquent toujours.",
@@ -953,7 +958,7 @@
"ui.ai_mode_edits_note": "Les commandes de modélisation s'exécutent sans demander ; l'enregistrement, l'ouverture, l'exportation et les autres actions sur les fichiers ou destructrices demandent toujours.",
"ui.ai_mode_global": "Comme défini ci-dessus",
"ui.ai_model": "Modèle",
"ui.ai_modes_scope": "Ces réglages concernent les outils de SolidCraft. Les outils propres à un agent (son shell, ses modifications de fichiers) suivent le mode de l'agent, que vous choisissez dans l'en-tête du panneau de chat lorsque l'agent propose des modes.",
"ui.ai_modes_scope": "Ces réglages concernent les outils de OneCAD. Les outils propres à un agent (son shell, ses modifications de fichiers) suivent le mode de l'agent, que vous choisissez dans l'en-tête du panneau de chat lorsque l'agent propose des modes.",
"ui.ai_name": "Nom",
"ui.ai_need_command": "Saisir la commande qui lance l'agent",
"ui.ai_need_model": "Saisir le nom du modèle",
@@ -1054,6 +1059,9 @@
"ui.construct": "Construire",
"ui.construction": "Construction",
"ui.context_menu": "Menu contextuel",
"ui.continuity_g0": "G0 angle vif",
"ui.continuity_g1": "G1 tangence",
"ui.continuity_g2": "G2 courbure",
"ui.copy": "Copier",
"ui.corner": "Angle",
"ui.corners": "Angles",
@@ -1141,6 +1149,8 @@
"ui.fully_constrained": "Entièrement contrainte",
"ui.general": "Général",
"ui.graphics": "Graphisme",
"ui.grid_follows_view": "La grille suit la vue",
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
"ui.ground_shadow": "Ombre au sol",
"ui.groupSelected": "Grouper la sélection",
"ui.heading_add_ins": "COMPLÉMENTS",
@@ -1472,31 +1482,31 @@
"ui.zoom": "Zoom",
"ui.zoom_to_cursor_tip": "La molette zoome vers le point sous le curseur (désactivé : vers le centre de la vue)",
"ui.zoom_to_the_cursor": "Zoom vers le curseur",
"update.available": "SolidCraft {version} est disponible",
"update.available": "OneCAD {version} est disponible",
"update.button": "Mettre à jour",
"update.button_tip": "SolidCraft {version} est disponible",
"update.button_tip": "OneCAD {version} est disponible",
"update.button_version": "Mettre à jour vers {version}",
"update.check_automatically": "Rechercher automatiquement les mises à jour",
"update.check_now": "Vérifier maintenant",
"update.checking": "Vérification…",
"update.copy": "Copier",
"update.dmg_opened": "L'image disque est ouverte et vérifiée : faites glisser SolidCraft dans Applications en remplaçant l'ancienne copie, puis redémarrez SolidCraft.",
"update.dmg_opened": "L'image disque est ouverte et vérifiée : faites glisser OneCAD dans Applications en remplaçant l'ancienne copie, puis redémarrez OneCAD.",
"update.download": "Télécharger",
"update.download_open": "Télécharger et ouvrir",
"update.downloading": "Téléchargement et vérification… {done} sur {total} Mo",
"update.failed": "Vérification impossible : {error}",
"update.file": "Le téléchargement récupère {file}.",
"update.install_failed": "La mise à jour ne s'est pas installée : {error}. Rien n'a été modifié ; Télécharger récupère plutôt le paquet.",
"update.install_msi": "Installer (ferme SolidCraft)",
"update.installed": "SolidCraft {version} est installé. Redémarrez pour l'utiliser.",
"update.installer_started": "L'installateur démarre à la fermeture de SolidCraft.",
"update.install_msi": "Installer (ferme OneCAD)",
"update.installed": "OneCAD {version} est installé. Redémarrez pour l'utiliser.",
"update.installer_started": "L'installateur démarre à la fermeture de OneCAD.",
"update.later": "Me le rappeler plus tard",
"update.no_notes": "Cette version n'a pas de notes.",
"update.package_hint": "Cette copie provient d'un {kind}. Téléchargez le nouveau paquet, puis installez-le avec :",
"update.prefs_dev": "Ceci est une version de développement ({version}) : elle ne vérifie pas les mises à jour.",
"update.prefs_flatpak": "Flatpak met à jour cette copie ({version}) : exécutez {command} ou utilisez votre logithèque. SolidCraft ne vérifie pas.",
"update.prefs_flatpak": "Flatpak met à jour cette copie ({version}) : exécutez {command} ou utilisez votre logithèque. OneCAD ne vérifie pas.",
"update.prefs_label": "Mises à jour",
"update.prefs_note": "Ceci est SolidCraft {version} ({kind}). Une vérification interroge GitHub sur la dernière version et n'envoie rien d'autre.",
"update.prefs_note": "Ceci est OneCAD {version} ({kind}). Une vérification interroge GitHub sur la dernière version et n'envoie rien d'autre.",
"update.release_page": "Ouvrir la page de la version",
"update.restart": "Redémarrer maintenant",
"update.restart_later": "Plus tard",
@@ -1504,7 +1514,7 @@
"update.skip": "Ignorer cette version",
"update.system_hint": "Cette copie est un {kind}. Mettez-la à jour avec :",
"update.title": "Mise à jour disponible",
"update.up_to_date": "SolidCraft est à jour.",
"update.up_to_date": "OneCAD est à jour.",
"update.update_now": "Mettre à jour maintenant",
"update.you_have": "Vous avez la version {version}."
}
+25 -25
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. Preme Submit new issue en GitHub para enviar o informe.",
"A new configuration, a copy of the active one": "Unha configuración nova, copia da activa",
"AI": "IA",
"About SolidCraft": "Acerca de SolidCraft",
"About OneCAD": "Acerca de OneCAD",
"Active": "Activa",
"Active rule": "Regra activa",
"Add": "Engadir",
@@ -239,7 +239,7 @@
"Sketch plane has no body-face attachment.": "O plano do bosquexo non está ancorado a ningunha cara dun corpo.",
"Sketch points": "Puntos do bosquexo",
"Slot width": "Largura da ranura",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft pechouse antes de gardar estes deseños. Pódese abrir a súa última gravación automática.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD pechouse antes de gardar estes deseños. Pódese abrir a súa última gravación automática.",
"Speed": "Velocidade",
"Stand it up": "Poñela de pé",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Poñela de pé: un cuarto de volta arredor de X, como unha operación Mover que podes desfacer.",
@@ -673,18 +673,18 @@
"home.report_issue": "Informar dunha incidencia",
"home.samples": "Exemplos",
"home.whats_new": "Novidades",
"install.button_tip": "Instala SolidCraft como aplicación: ábrese na súa propia xanela e funciona sen conexión",
"install.button_tip": "Instala OneCAD como aplicación: ábrese na súa propia xanela e funciona sen conexión",
"install.dock_1": "Escolle Ficheiro › Engadir ao Dock.",
"install.dock_2": "Fai clic en Engadir.",
"install.dock_after": "Despois, SolidCraft ábrese na súa propia xanela desde o Dock e funciona sen conexión.",
"install.dock_after": "Despois, OneCAD ábrese na súa propia xanela desde o Dock e funciona sen conexión.",
"install.done": "Feito",
"install.home_screen_1": "Toca Compartir (no menú … se non está na barra de ferramentas).",
"install.home_screen_2": "Escolle Engadir á pantalla de inicio.",
"install.home_screen_3": "Toca Engadir.",
"install.home_screen_after": "Despois, SolidCraft ábrese desde a súa icona na pantalla de inicio, sen as barras de Safari, e funciona sen conexión.",
"install.home_screen_after": "Despois, OneCAD ábrese desde a súa icona na pantalla de inicio, sen as barras de Safari, e funciona sen conexión.",
"install.menu": "Instalar a aplicación…",
"install.steps_intro": "Safari instala as aplicacións web desde os seus propios menús:",
"install.title": "Instalar SolidCraft",
"install.title": "Instalar OneCAD",
"joint.from_bodies_hint": "Cada corpo escollido pasa a un compoñente novo propio e queda onde está.",
"joint.one_body": "As dúas seleccións están en {body}. Escolle a segunda noutra peza.",
"joint.one_component": "Estas pezas son corpos dun mesmo compoñente: {bodies}. Unha unión move un compoñente respecto doutro, así que cada peza precisa o seu propio compoñente.",
@@ -764,10 +764,10 @@
"settings.frame_rate_note": "Mostra as imaxes por segundo na esquina inferior dereita da vista. Mentres está activado, a vista redebúxase continuamente.",
"settings.measure_axis_distances": "Mostrar as distancias en X/Y/Z",
"settings.measure_axis_distances_help": "Mostra as compoñentes da distancia segundo os eixes do mundo xunto á distancia total mentres Medir está aberto.",
"settings.msaa_applies_on_restart": "O antialiasing aplicarase a próxima vez que se inicie SolidCraft",
"settings.msaa_applies_on_restart": "O antialiasing aplicarase a próxima vez que se inicie OneCAD",
"settings.msaa_reset": "{setting}: este dispositivo gráfico non admite {requested}×; agora é {reset}× (admitidos: {supported}).",
"settings.msaa_unsupported": "Non admitido por este dispositivo gráfico",
"settings.reset_intro": "Estes axustes non funcionan neste ordenador e impedirían que SolidCraft se iniciase, así que se cambiaron:",
"settings.reset_intro": "Estes axustes non funcionan neste ordenador e impedirían que OneCAD se iniciase, así que se cambiaron:",
"settings.reset_title": "Cambiáronse os axustes",
"settings.supersampling_note": "Debuxa a vista a unha resolución maior e fíltraa cara abaixo: arestas, liñas e grella máis suaves en pantallas por debaixo de 4K. Consome tempo de GPU: uns 2,25× píxeles a 1,5× e 4× a 2×.",
"sketch.degrees_of_freedom": "{count} graos de liberdade",
@@ -814,8 +814,8 @@
"ui.agent_chat_attach_view": "Achegar a vista",
"ui.agent_chat_attach_view_tip": "Envía unha imaxe da vista actual coa mensaxe",
"ui.agent_chat_auth_agent": "Iniciar sesión con {name}",
"ui.agent_chat_auth_env": "Define {vars} no teu contorno e reinicia SolidCraft.",
"ui.agent_chat_auth_needed": "O axente pídeche que inicies sesión. Inicias sesión co propio axente; SolidCraft nunca ve o teu contrasinal nin as túas claves.",
"ui.agent_chat_auth_env": "Define {vars} no teu contorno e reinicia OneCAD.",
"ui.agent_chat_auth_needed": "O axente pídeche que inicies sesión. Inicias sesión co propio axente; OneCAD nunca ve o teu contrasinal nin as túas claves.",
"ui.agent_chat_auth_none": "O axente non ofrece aquí ningún método para iniciar sesión: inicia sesión como indica a súa propia documentación.",
"ui.agent_chat_auth_retry": "Xa iniciei sesión: conectar de novo",
"ui.agent_chat_auth_terminal": "Executa isto nun terminal e despois preme Conectar de novo:",
@@ -837,7 +837,7 @@
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_exited": "O axente detívose.",
"ui.agent_chat_failed_start": "Non se puido iniciar o axente: {error}",
"ui.agent_chat_intro": "Conversa cun axente de programación que xa uses, ou cun punto de acceso de modelo que escollas. Traballa no deseño aberto a través das ferramentas de SolidCraft: os seus cambios vense mentres os fai e desfanse nun paso por mensaxe. O que pode facer sen preguntar defínese en Preferencias > IA.",
"ui.agent_chat_intro": "Conversa cun axente de programación que xa uses, ou cun punto de acceso de modelo que escollas. Traballa no deseño aberto a través das ferramentas de OneCAD: os seus cambios vense mentres os fai e desfanse nun paso por mensaxe. O que pode facer sen preguntar defínese en Preferencias > IA.",
"ui.agent_chat_key_in_env": "clave configurada",
"ui.agent_chat_left": "Esquerda",
"ui.agent_chat_limit": "O axente chegou ao límite de {limit} cambios por mensaxe. Envía outra mensaxe para que continúe.",
@@ -858,7 +858,7 @@
"ui.agent_chat_placeholder": "Pídelle ao axente que cambie o deseño…",
"ui.agent_chat_placeholder_busy": "Pon unha mensaxe na cola para cando remate o axente (Ctrl+Intro: enviar agora)",
"ui.agent_chat_privacy": "Nada vai a ningún sitio agás ao axente que executas, coa túa propia conta.",
"ui.agent_chat_privacy_note": "SolidCraft non envía nada a ningún sitio: o axente que executas fala co seu propio servizo coa túa propia conta. SolidCraft nunca ve nin garda ningunha clave nin contrasinal.",
"ui.agent_chat_privacy_note": "OneCAD non envía nada a ningún sitio: o axente que executas fala co seu propio servizo coa túa propia conta. OneCAD nunca ve nin garda ningunha clave nin contrasinal.",
"ui.agent_chat_queue": "Pór na cola",
"ui.agent_chat_queue_full": "Hai demasiadas mensaxes agardando: quita unha ou agarda polo axente.",
"ui.agent_chat_queued": "Na cola",
@@ -897,7 +897,7 @@
"ui.ai_add_installed": "Axente instalado",
"ui.ai_add_installed_note": "Unha entrada propia para un axente instalado, por exemplo con outros argumentos. Os axentes instalados lístanse por si sós.",
"ui.ai_added": "engadido",
"ui.ai_agent_mode_tip": "O modo propio do axente: as súas propias ferramentas (shell, edición de ficheiros) seguen ese modo. As ferramentas de SolidCraft seguen os permisos de Preferencias > IA.",
"ui.ai_agent_mode_tip": "O modo propio do axente: as súas propias ferramentas (shell, edición de ficheiros) seguen ese modo. As ferramentas de OneCAD seguen os permisos de Preferencias > IA.",
"ui.ai_agents": "Axentes",
"ui.ai_always_allow": "Permitir sempre",
"ui.ai_always_allow_tip": "Permitir {tool} a este axente de agora en diante, sen preguntar",
@@ -905,7 +905,7 @@
"ui.ai_always_allowed_none": "Aínda non hai nada permitido sempre. As respostas «Permitir sempre» que deas no chat lístanse aquí.",
"ui.ai_auto_allowed": "Permitido automaticamente: {title} ({why})",
"ui.ai_auto_mode": "a túa configuración de permisos",
"ui.ai_auto_ours": "SolidCraft comproba as súas propias ferramentas",
"ui.ai_auto_ours": "OneCAD comproba as súas propias ferramentas",
"ui.ai_auto_remembered": "escolliches Permitir sempre",
"ui.ai_bad_url": "Introduce un URL que comece por http:// ou https://",
"ui.ai_base_url": "URL base",
@@ -919,7 +919,7 @@
"ui.ai_key": "Clave da API",
"ui.ai_key_hint": "opcional; gárdase no chaveiro do sistema",
"ui.ai_key_needs_https": "A clave só se envía por HTTPS ou a este ordenador.",
"ui.ai_key_note": "Gárdase no chaveiro do sistema, nunca nos axustes de SolidCraft. Déixaa baleira para un servidor local.",
"ui.ai_key_note": "Gárdase no chaveiro do sistema, nunca nos axustes de OneCAD. Déixaa baleira para un servidor local.",
"ui.ai_keychain_error": "O chaveiro do sistema rexeitouno: {error}",
"ui.ai_mode_all": "Permitir todo",
"ui.ai_mode_all_note": "Nada pregunta antes. Os informes de fallos seguen fóra do seu alcance e os límites de cartafoles seguen aplicándose.",
@@ -930,7 +930,7 @@
"ui.ai_mode_edits_note": "As ordes de modelado execútanse sen preguntar; gardar, abrir, exportar e outras accións con ficheiros ou destrutivas seguen preguntando.",
"ui.ai_mode_global": "Como se definiu arriba",
"ui.ai_model": "Modelo",
"ui.ai_modes_scope": "Estes axustes cobren as ferramentas de SolidCraft. As ferramentas propias dun axente (o seu shell, as súas edicións de ficheiros) seguen o modo propio do axente, que escolles na cabeceira do panel de chat cando o axente ten modos.",
"ui.ai_modes_scope": "Estes axustes cobren as ferramentas de OneCAD. As ferramentas propias dun axente (o seu shell, as súas edicións de ficheiros) seguen o modo propio do axente, que escolles na cabeceira do panel de chat cando o axente ten modos.",
"ui.ai_name": "Nome",
"ui.ai_need_command": "Introduce a orde que inicia o axente",
"ui.ai_need_model": "Introduce o nome do modelo",
@@ -1445,31 +1445,31 @@
"ui.zoom": "Zoom",
"ui.zoom_to_cursor_tip": "A roda fai zoom cara ao punto baixo o cursor (desactivado: cara ao centro da vista)",
"ui.zoom_to_the_cursor": "Zoom cara ao cursor",
"update.available": "Está dispoñible SolidCraft {version}",
"update.available": "Está dispoñible OneCAD {version}",
"update.button": "Actualizar",
"update.button_tip": "Está dispoñible SolidCraft {version}",
"update.button_tip": "Está dispoñible OneCAD {version}",
"update.button_version": "Actualizar a {version}",
"update.check_automatically": "Buscar actualizacións automaticamente",
"update.check_now": "Comprobar agora",
"update.checking": "Comprobando…",
"update.copy": "Copiar",
"update.dmg_opened": "A imaxe de disco está aberta e verificada: arrastra SolidCraft a Aplicacións, substituíndo a copia antiga, e despois reinicia SolidCraft.",
"update.dmg_opened": "A imaxe de disco está aberta e verificada: arrastra OneCAD a Aplicacións, substituíndo a copia antiga, e despois reinicia OneCAD.",
"update.download": "Descargar",
"update.download_open": "Descargar e abrir",
"update.downloading": "Descargando e verificando… {done} de {total} MB",
"update.failed": "Non se puido comprobar: {error}",
"update.file": "Descargar obtén {file}.",
"update.install_failed": "A actualización non se instalou: {error}. Non se cambiou nada; Descargar obtén o paquete no seu lugar.",
"update.install_msi": "Instalar (pecha SolidCraft)",
"update.installed": "SolidCraft {version} está instalado. Reinicia para usalo.",
"update.installer_started": "O instalador iníciase cando se pecha SolidCraft.",
"update.install_msi": "Instalar (pecha OneCAD)",
"update.installed": "OneCAD {version} está instalado. Reinicia para usalo.",
"update.installer_started": "O instalador iníciase cando se pecha OneCAD.",
"update.later": "Lembrarmo máis tarde",
"update.no_notes": "Esta versión non ten notas.",
"update.package_hint": "Esta copia veu dun {kind}. Descarga o paquete novo e despois instálao con:",
"update.prefs_dev": "Esta é unha compilación de desenvolvemento ({version}): non busca actualizacións.",
"update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): executa {command} ou usa o teu centro de software. SolidCraft non comproba.",
"update.prefs_flatpak": "Flatpak actualiza esta copia ({version}): executa {command} ou usa o teu centro de software. OneCAD non comproba.",
"update.prefs_label": "Actualizacións",
"update.prefs_note": "Esta é a versión {version} de SolidCraft ({kind}). Unha comprobación pídelle a GitHub a última versión e non envía nada máis.",
"update.prefs_note": "Esta é a versión {version} de OneCAD ({kind}). Unha comprobación pídelle a GitHub a última versión e non envía nada máis.",
"update.release_page": "Abrir a páxina da versión",
"update.restart": "Reiniciar agora",
"update.restart_later": "Máis tarde",
@@ -1477,7 +1477,7 @@
"update.skip": "Omitir esta versión",
"update.system_hint": "Esta copia é un {kind}. Actualízaa con:",
"update.title": "Actualización dispoñible",
"update.up_to_date": "SolidCraft está actualizado.",
"update.up_to_date": "OneCAD está actualizado.",
"update.update_now": "Actualizar agora",
"update.you_have": "Tes a versión {version}."
}
File diff suppressed because it is too large. Load diff
+34 -27
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. GitHubでSubmit new issueをクリックしてレポートを送信します。",
"A new configuration, a copy of the active one": "アクティブな構成をコピーして新しい構成を作成",
"AI": "AI",
"About SolidCraft": "SolidCraft について",
"About OneCAD": "OneCAD について",
"Acceleration": "加速度",
"Active": "アクティブ",
"Active rule": "有効なルール",
@@ -31,6 +31,7 @@
"Arc length": "円弧の長さ",
"Area": "表面積",
"Attachment": "アタッチメント",
"BOUNDARY SURFACE": "境界サーフェス",
"BROWSER": "ブラウザ",
"Bend Line Position": "曲げ線位置",
"Bend Position": "曲げ位置",
@@ -73,6 +74,7 @@
"Counterbore depth": "座ぐり深さ",
"Countersink angle": "皿穴角度",
"Create Issue — review draft": "Issueを作成 — 下書きの確認",
"Cross curves": "交差する曲線",
"Cube": "キューブ",
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
"Current perspective view": "現在の透視投影",
@@ -111,7 +113,7 @@
"Faces": "面",
"Faces / edges / vertices": "面 / エッジ / 頂点",
"Favourite": "お気に入り",
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "多くのCADではY軸を上向きとしているため、Z軸を上向きとするSolidCraftでは、読み込んだモデルが横倒しの向きで表示されることがあります。",
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "多くのCADではY軸を上向きとしているため、Z軸を上向きとするOneCADでは、読み込んだモデルが横倒しの向きで表示されることがあります。",
"Filter": "フィルター",
"Find in Browser": "ブラウザで検索",
"Flat": "展開",
@@ -252,7 +254,7 @@
"Sketch plane has no body-face attachment.": "スケッチ平面にボディ面のアタッチメントがありません。",
"Sketch points": "スケッチ点",
"Slot width": "長穴の幅",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "これらのデザインが保存される前にSolidCraftが終了しました。最後の自動保存ファイルを開くことができます。",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "これらのデザインが保存される前にOneCADが終了しました。最後の自動保存ファイルを開くことができます。",
"Speed": "速度",
"Stand it up": "正立させる",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "正立させる: 取り消し可能な1つの移動フィーチャーとして、X軸周りに90度回転します。",
@@ -695,18 +697,18 @@
"home.report_issue": "問題を報告",
"home.samples": "サンプル",
"home.whats_new": "新機能",
"install.button_tip": "SolidCraft をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます",
"install.button_tip": "OneCAD をアプリとしてインストールします。専用のウィンドウで開き、オフラインでも使えます",
"install.dock_1": "「ファイル」›「Dockに追加」を選びます。",
"install.dock_2": "「追加」をクリックします。",
"install.dock_after": "SolidCraft は Dock から専用のウィンドウで開き、オフラインでも使えます。",
"install.dock_after": "OneCAD は Dock から専用のウィンドウで開き、オフラインでも使えます。",
"install.done": "完了",
"install.home_screen_1": "「共有」をタップします(ツールバーにない場合は … メニューの中にあります)。",
"install.home_screen_2": "「ホーム画面に追加」を選びます。",
"install.home_screen_3": "「追加」をタップします。",
"install.home_screen_after": "SolidCraft はホーム画面のアイコンから Safari のツールバーなしで開き、オフラインでも使えます。",
"install.home_screen_after": "OneCAD はホーム画面のアイコンから Safari のツールバーなしで開き、オフラインでも使えます。",
"install.menu": "アプリをインストール…",
"install.steps_intro": "Safari では、Web アプリを Safari 自身のメニューからインストールします:",
"install.title": "SolidCraft をインストール",
"install.title": "OneCAD をインストール",
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。",
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
@@ -790,10 +792,10 @@
"settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。オンの間、ビューは常に再描画されます。",
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます",
"settings.msaa_applies_on_restart": "アンチエイリアスは次回 OneCAD を起動したときに適用されます",
"settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。",
"settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません",
"settings.reset_intro": "次の設定はこのコンピューターでは使用できず、SolidCraft が起動できなくなるため変更されました:",
"settings.reset_intro": "次の設定はこのコンピューターでは使用できず、OneCAD が起動できなくなるため変更されました:",
"settings.reset_title": "設定が変更されました",
"settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。",
"sketch.degrees_of_freedom": "自由度 {count}",
@@ -835,7 +837,7 @@
"ui.3d_mouse_speed": "3D マウス速度",
"ui.3d_mouse_windows": "3D マウス",
"ui.3d_sketch": "3D スケッチ",
"ui.about": "SolidCraft について",
"ui.about": "OneCAD について",
"ui.activate": "アクティブ化",
"ui.active_component": "アクティブなコンポーネント",
"ui.agent_chat": "エージェントチャット",
@@ -846,8 +848,8 @@
"ui.agent_chat_attach_view": "ビューを添付",
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
"ui.agent_chat_auth_agent": "{name} でサインイン",
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して OneCAD を再起動してください。",
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。OneCAD がパスワードやキーを見ることはありません。",
"ui.agent_chat_auth_none": "このエージェントはここでのサインイン方法を提示していません。エージェントのドキュメントに従ってサインインしてください。",
"ui.agent_chat_auth_retry": "サインインしました: 再接続",
"ui.agent_chat_auth_terminal": "ターミナルでこれを実行してから、もう一度「接続」を押してください:",
@@ -864,12 +866,13 @@
"ui.agent_chat_deny": "拒否",
"ui.agent_chat_desktop_only": "エージェントチャットにはデスクトップアプリが必要です。",
"ui.agent_chat_detecting": "エージェントを検索中…",
"ui.agent_chat_dock": "サイドパネルに戻す",
"ui.agent_chat_dock_left": "左にドッキング",
"ui.agent_chat_dock_right": "右にドッキング",
"ui.agent_chat_edit": "編集",
"ui.agent_chat_exited": "エージェントが停止しました。",
"ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}",
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。OneCAD のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
"ui.agent_chat_key_in_env": "キー設定済み",
"ui.agent_chat_left": "左",
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
@@ -885,12 +888,14 @@
"ui.agent_chat_none_found_short": "エージェントが見つかりません",
"ui.agent_chat_not_installed": "未インストール",
"ui.agent_chat_not_signed_in": "未サインイン",
"ui.agent_chat_own_window": "独立したウィンドウ",
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
"ui.agent_chat_pick": "エージェント",
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
"ui.agent_chat_placeholder_busy": "エージェントの作業が終わったら送るメッセージを入力(Ctrl+Enter: 今すぐ送信)",
"ui.agent_chat_pop_out": "独立したウィンドウで開く(別の画面に移動できます)",
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
"ui.agent_chat_privacy_note": "OneCAD はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。OneCAD がキーやパスワードを見たり保存したりすることはありません。",
"ui.agent_chat_queue": "待機に追加",
"ui.agent_chat_queue_full": "待機中のメッセージが多すぎます。削除するか、エージェントの完了をお待ちください。",
"ui.agent_chat_queued": "待機中",
@@ -929,7 +934,7 @@
"ui.ai_add_installed": "インストール済みエージェント",
"ui.ai_add_installed_note": "インストール済みエージェントの独自エントリ(例: 別の引数で起動)。インストール済みのエージェントは自動で一覧に表示されます。",
"ui.ai_added": "追加済み",
"ui.ai_agent_mode_tip": "エージェント自身のモード: エージェント自身のツール(シェル、ファイル編集)はこれに従います。SolidCraft のツールは「環境設定 > AI」の権限に従います。",
"ui.ai_agent_mode_tip": "エージェント自身のモード: エージェント自身のツール(シェル、ファイル編集)はこれに従います。OneCAD のツールは「環境設定 > AI」の権限に従います。",
"ui.ai_agents": "エージェント",
"ui.ai_always_allow": "常に許可",
"ui.ai_always_allow_tip": "今後このエージェントに {tool} を確認なしで許可します",
@@ -937,7 +942,7 @@
"ui.ai_always_allowed_none": "まだ常に許可されたものはありません。チャットで「常に許可」と答えたものがここに表示されます。",
"ui.ai_auto_allowed": "自動許可: {title}({why})",
"ui.ai_auto_mode": "権限の設定",
"ui.ai_auto_ours": "SolidCraft のツールは SolidCraft 自身が確認します",
"ui.ai_auto_ours": "OneCAD のツールは OneCAD 自身が確認します",
"ui.ai_auto_remembered": "「常に許可」を選択済み",
"ui.ai_bad_url": "http:// または https:// で始まる URL を入力してください",
"ui.ai_base_url": "ベース URL",
@@ -951,7 +956,7 @@
"ui.ai_key": "API キー",
"ui.ai_key_hint": "任意。システムのキーチェーンに保存",
"ui.ai_key_needs_https": "キーは HTTPS 経由、またはこのコンピューター宛てにのみ送信されます。",
"ui.ai_key_note": "システムのキーチェーンに保存され、SolidCraft の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
"ui.ai_key_note": "システムのキーチェーンに保存され、OneCAD の設定には保存されません。ローカルサーバーの場合は空欄のままにします。",
"ui.ai_keychain_error": "システムのキーチェーンが拒否しました: {error}",
"ui.ai_mode_all": "すべて許可",
"ui.ai_mode_all_note": "確認なしで実行します。クラッシュレポートは引き続き対象外で、フォルダー制限も有効です。",
@@ -962,7 +967,7 @@
"ui.ai_mode_edits_note": "モデリングコマンドは確認なしで実行されます。保存・開く・エクスポートなどのファイル操作や破壊的な操作は引き続き確認します。",
"ui.ai_mode_global": "上の設定に従う",
"ui.ai_model": "モデル",
"ui.ai_modes_scope": "これらの設定は SolidCraft のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。",
"ui.ai_modes_scope": "これらの設定は OneCAD のツールに適用されます。エージェント自身のツール(シェル、ファイル編集)は、エージェントにモードがある場合にチャットパネルのヘッダーで選ぶエージェント自身のモードに従います。",
"ui.ai_name": "名前",
"ui.ai_need_command": "エージェントを起動するコマンドを入力してください",
"ui.ai_need_model": "モデル名を入力してください",
@@ -1367,6 +1372,8 @@
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
"ui.restore": "復元",
"ui.restore_defaults": "初期値に戻す",
"ui.reverse_horizontal_pan_direction": "水平パンの方向を反転",
"ui.reverse_horizontal_pan_tip": "水平方向の2本指スクロールとサムホイールで、反対方向にパンします",
"ui.reverse_zoom_direction": "ズーム方向を反転",
"ui.revolve": "回転",
"ui.right": "右側面",
@@ -1490,31 +1497,31 @@
"ui.zoom": "ズーム",
"ui.zoom_to_cursor_tip": "マウスホイールでカーソル位置を基準にズームします(オフの場合はビューの中心を基準にズームします)。",
"ui.zoom_to_the_cursor": "カーソル位置へズーム",
"update.available": "SolidCraft {version} が利用できます",
"update.available": "OneCAD {version} が利用できます",
"update.button": "アップデート",
"update.button_tip": "SolidCraft {version} が利用できます",
"update.button_tip": "OneCAD {version} が利用できます",
"update.button_version": "{version} にアップデート",
"update.check_automatically": "アップデートを自動的に確認",
"update.check_now": "今すぐ確認",
"update.checking": "確認中…",
"update.copy": "コピー",
"update.dmg_opened": "ディスクイメージを検証して開きました: SolidCraft をアプリケーションフォルダにドラッグして古いものを置き換え、SolidCraft を再起動してください。",
"update.dmg_opened": "ディスクイメージを検証して開きました: OneCAD をアプリケーションフォルダにドラッグして古いものを置き換え、OneCAD を再起動してください。",
"update.download": "ダウンロード",
"update.download_open": "ダウンロードして開く",
"update.downloading": "ダウンロードと検証中… {done} / {total} MB",
"update.failed": "確認できませんでした: {error}",
"update.file": "ダウンロードするファイル: {file}",
"update.install_failed": "アップデートをインストールできませんでした: {error}。何も変更されていません。「ダウンロード」でパッケージを入手できます。",
"update.install_msi": "インストール (SolidCraft を終了)",
"update.installed": "SolidCraft {version} をインストールしました。再起動すると使えます。",
"update.installer_started": "SolidCraft の終了後にインストーラーが起動します。",
"update.install_msi": "インストール (OneCAD を終了)",
"update.installed": "OneCAD {version} をインストールしました。再起動すると使えます。",
"update.installer_started": "OneCAD の終了後にインストーラーが起動します。",
"update.later": "後で通知",
"update.no_notes": "このリリースにはリリースノートがありません。",
"update.package_hint": "このコピーは {kind} からインストールされました。新しいパッケージをダウンロードし、次のコマンドでインストールします:",
"update.prefs_dev": "これは開発ビルド ({version}) のため、アップデートを確認しません。",
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。SolidCraft 自身は確認しません。",
"update.prefs_flatpak": "このコピー ({version}) は Flatpak が更新します: {command} を実行するか、ソフトウェアセンターを使ってください。OneCAD 自身は確認しません。",
"update.prefs_label": "アップデート",
"update.prefs_note": "これは SolidCraft {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
"update.prefs_note": "これは OneCAD {version} ({kind}) です。確認では GitHub に最新リリースを問い合わせるだけで、ほかには何も送信しません。",
"update.release_page": "リリースページを開く",
"update.restart": "今すぐ再起動",
"update.restart_later": "後で",
@@ -1522,7 +1529,7 @@
"update.skip": "このバージョンをスキップ",
"update.system_hint": "このコピーは {kind} です。次のコマンドで更新します:",
"update.title": "アップデートがあります",
"update.up_to_date": "SolidCraft は最新です。",
"update.up_to_date": "OneCAD は最新です。",
"update.update_now": "今すぐ更新",
"update.you_have": "現在のバージョンは {version} です。"
}
+23 -23
View File
@@ -486,18 +486,18 @@
"home.report_issue": "문제 신고",
"home.samples": "샘플",
"home.whats_new": "새로운 기능",
"install.button_tip": "SolidCraft를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다",
"install.button_tip": "OneCAD를 앱으로 설치합니다. 별도 창에서 열리며 오프라인에서도 작동합니다",
"install.dock_1": "파일 › Dock에 추가를 선택합니다.",
"install.dock_2": "추가를 클릭합니다.",
"install.dock_after": "이후 SolidCraft는 Dock에서 별도 창으로 열리며 오프라인에서도 작동합니다.",
"install.dock_after": "이후 OneCAD는 Dock에서 별도 창으로 열리며 오프라인에서도 작동합니다.",
"install.done": "완료",
"install.home_screen_1": "공유를 탭합니다(도구 막대에 없으면 … 메뉴 안에 있습니다).",
"install.home_screen_2": "홈 화면에 추가를 선택합니다.",
"install.home_screen_3": "추가를 탭합니다.",
"install.home_screen_after": "이후 SolidCraft는 홈 화면의 아이콘에서 Safari 도구 막대 없이 열리며 오프라인에서도 작동합니다.",
"install.home_screen_after": "이후 OneCAD는 홈 화면의 아이콘에서 Safari 도구 막대 없이 열리며 오프라인에서도 작동합니다.",
"install.menu": "앱 설치…",
"install.steps_intro": "Safari는 자체 메뉴에서 웹 앱을 설치합니다:",
"install.title": "SolidCraft 설치",
"install.title": "OneCAD 설치",
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.",
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
@@ -572,10 +572,10 @@
"settings.frame_rate_note": "뷰의 오른쪽 아래에 초당 프레임 수를 표시합니다. 켜져 있는 동안 뷰를 계속 다시 그립니다.",
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다",
"settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 OneCAD를 시작할 때 적용됩니다",
"settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).",
"settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음",
"settings.reset_intro": "다음 설정은 이 컴퓨터에서 작동하지 않아 SolidCraft를 시작할 수 없게 하므로 변경되었습니다:",
"settings.reset_intro": "다음 설정은 이 컴퓨터에서 작동하지 않아 OneCAD를 시작할 수 없게 하므로 변경되었습니다:",
"settings.reset_title": "설정이 변경되었습니다",
"settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.",
"sketch.degrees_of_freedom": "자유도 {count}개",
@@ -618,8 +618,8 @@
"ui.agent_chat_attach_view": "뷰 첨부",
"ui.agent_chat_attach_view_tip": "현재 뷰의 이미지를 메시지와 함께 보냅니다",
"ui.agent_chat_auth_agent": "{name}(으)로 로그인",
"ui.agent_chat_auth_env": "환경 변수 {vars}을(를) 설정하고 SolidCraft를 다시 시작하세요.",
"ui.agent_chat_auth_needed": "에이전트가 로그인을 요청합니다. 로그인은 에이전트에서 직접 하며, SolidCraft는 비밀번호나 키를 보지 않습니다.",
"ui.agent_chat_auth_env": "환경 변수 {vars}을(를) 설정하고 OneCAD를 다시 시작하세요.",
"ui.agent_chat_auth_needed": "에이전트가 로그인을 요청합니다. 로그인은 에이전트에서 직접 하며, OneCAD는 비밀번호나 키를 보지 않습니다.",
"ui.agent_chat_auth_none": "이 에이전트는 여기서 로그인 방법을 제공하지 않습니다. 에이전트 문서에 안내된 방법으로 로그인하세요.",
"ui.agent_chat_auth_retry": "로그인했습니다: 다시 연결",
"ui.agent_chat_auth_terminal": "터미널에서 이것을 실행한 뒤 다시 연결을 누르세요:",
@@ -640,7 +640,7 @@
"ui.agent_chat_dock_right": "오른쪽에 고정",
"ui.agent_chat_exited": "에이전트가 중지되었습니다.",
"ui.agent_chat_failed_start": "에이전트를 시작할 수 없습니다: {error}",
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. SolidCraft 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
"ui.agent_chat_intro": "이미 사용 중인 코딩 에이전트나 선택한 모델 엔드포인트와 채팅하세요. OneCAD 도구로 열린 디자인을 다루며, 변경 사항은 바로 보이고 메시지마다 한 번에 실행 취소할 수 있습니다. 묻지 않고 할 수 있는 일은 환경 설정 > AI에서 정합니다.",
"ui.agent_chat_key_in_env": "키 설정됨",
"ui.agent_chat_left": "왼쪽",
"ui.agent_chat_limit": "에이전트가 메시지당 변경 한도 {limit}에 도달했습니다. 계속하게 하려면 메시지를 하나 더 보내세요.",
@@ -656,7 +656,7 @@
"ui.agent_chat_pick": "에이전트",
"ui.agent_chat_placeholder": "에이전트에게 디자인 변경을 요청하세요…",
"ui.agent_chat_privacy": "사용자의 계정으로 실행하는 에이전트 외에는 아무 데도 전송되지 않습니다.",
"ui.agent_chat_privacy_note": "SolidCraft는 아무것도 어디로도 보내지 않습니다. 실행한 에이전트가 사용자의 계정으로 자신의 서비스와 통신합니다. SolidCraft는 키나 비밀번호를 보거나 저장하지 않습니다.",
"ui.agent_chat_privacy_note": "OneCAD는 아무것도 어디로도 보내지 않습니다. 실행한 에이전트가 사용자의 계정으로 자신의 서비스와 통신합니다. OneCAD는 키나 비밀번호를 보거나 저장하지 않습니다.",
"ui.agent_chat_refresh": "새로 고침",
"ui.agent_chat_refused": "에이전트에게 허용되지 않음: {reason}",
"ui.agent_chat_right": "오른쪽",
@@ -686,7 +686,7 @@
"ui.ai_add_installed": "설치된 에이전트",
"ui.ai_add_installed_note": "설치된 에이전트에 대한 별도 항목(예: 다른 인수로 실행). 설치된 에이전트는 자동으로 표시됩니다.",
"ui.ai_added": "추가됨",
"ui.ai_agent_mode_tip": "에이전트 자체 모드: 에이전트 자체 도구(셸, 파일 편집)는 이 모드를 따릅니다. SolidCraft 도구는 환경 설정 > AI의 권한을 따릅니다.",
"ui.ai_agent_mode_tip": "에이전트 자체 모드: 에이전트 자체 도구(셸, 파일 편집)는 이 모드를 따릅니다. OneCAD 도구는 환경 설정 > AI의 권한을 따릅니다.",
"ui.ai_agents": "에이전트",
"ui.ai_always_allow": "항상 허용",
"ui.ai_always_allow_tip": "이 에이전트에게 앞으로 {tool}을(를) 묻지 않고 허용",
@@ -694,7 +694,7 @@
"ui.ai_always_allowed_none": "아직 항상 허용된 것이 없습니다. 채팅에서 \"항상 허용\"으로 답한 항목이 여기에 표시됩니다.",
"ui.ai_auto_allowed": "자동 허용: {title} ({why})",
"ui.ai_auto_mode": "권한 설정",
"ui.ai_auto_ours": "SolidCraft가 자체 도구를 직접 확인합니다",
"ui.ai_auto_ours": "OneCAD가 자체 도구를 직접 확인합니다",
"ui.ai_auto_remembered": "\"항상 허용\"을 선택함",
"ui.ai_bad_url": "http:// 또는 https://로 시작하는 URL을 입력하세요",
"ui.ai_base_url": "기본 URL",
@@ -708,7 +708,7 @@
"ui.ai_key": "API 키",
"ui.ai_key_hint": "선택 사항, 시스템 키체인에 저장",
"ui.ai_key_needs_https": "키는 HTTPS를 통해서만, 또는 이 컴퓨터로만 전송됩니다.",
"ui.ai_key_note": "SolidCraft 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
"ui.ai_key_note": "OneCAD 설정이 아닌 시스템 키체인에 저장됩니다. 로컬 서버라면 비워 두세요.",
"ui.ai_keychain_error": "시스템 키체인이 거부했습니다: {error}",
"ui.ai_mode_all": "모두 허용",
"ui.ai_mode_all_note": "묻지 않고 실행합니다. 충돌 보고서는 계속 제외되고 폴더 제한도 그대로 적용됩니다.",
@@ -719,7 +719,7 @@
"ui.ai_mode_edits_note": "모델링 명령은 묻지 않고 실행되며, 저장·열기·내보내기 등 파일 작업이나 파괴적인 작업은 계속 묻습니다.",
"ui.ai_mode_global": "위 설정 따름",
"ui.ai_model": "모델",
"ui.ai_modes_scope": "이 설정은 SolidCraft 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.",
"ui.ai_modes_scope": "이 설정은 OneCAD 도구에 적용됩니다. 에이전트 자체 도구(셸, 파일 편집)는 에이전트에 모드가 있을 때 채팅 패널 머리글에서 고르는 에이전트 자체 모드를 따릅니다.",
"ui.ai_name": "이름",
"ui.ai_need_command": "에이전트를 시작하는 명령을 입력하세요",
"ui.ai_need_model": "모델 이름을 입력하세요",
@@ -1202,31 +1202,31 @@
"ui.y_distance": "Y 거리",
"ui.zoom": "확대/축소",
"ui.zoom_to_the_cursor": "커서 위치로 확대/축소",
"update.available": "SolidCraft {version} 사용 가능",
"update.available": "OneCAD {version} 사용 가능",
"update.button": "업데이트",
"update.button_tip": "SolidCraft {version} 사용 가능",
"update.button_tip": "OneCAD {version} 사용 가능",
"update.button_version": "{version}(으)로 업데이트",
"update.check_automatically": "업데이트 자동 확인",
"update.check_now": "지금 확인",
"update.checking": "확인 중…",
"update.copy": "복사",
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: SolidCraft를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 SolidCraft를 다시 시작하세요.",
"update.dmg_opened": "디스크 이미지를 확인하고 열었습니다: OneCAD를 응용 프로그램 폴더로 끌어 기존 버전을 바꾼 뒤 OneCAD를 다시 시작하세요.",
"update.download": "다운로드",
"update.download_open": "다운로드 후 열기",
"update.downloading": "다운로드 및 확인 중… {done} / {total} MB",
"update.failed": "확인할 수 없습니다: {error}",
"update.file": "내려받을 파일: {file}",
"update.install_failed": "업데이트를 설치하지 못했습니다: {error}. 아무것도 변경되지 않았습니다. 다운로드로 패키지를 받을 수 있습니다.",
"update.install_msi": "설치 (SolidCraft 종료)",
"update.installed": "SolidCraft {version}이(가) 설치되었습니다. 다시 시작하면 사용할 수 있습니다.",
"update.installer_started": "SolidCraft가 종료되면 설치 프로그램이 시작됩니다.",
"update.install_msi": "설치 (OneCAD 종료)",
"update.installed": "OneCAD {version}이(가) 설치되었습니다. 다시 시작하면 사용할 수 있습니다.",
"update.installer_started": "OneCAD가 종료되면 설치 프로그램이 시작됩니다.",
"update.later": "나중에 알림",
"update.no_notes": "이 릴리스에는 릴리스 노트가 없습니다.",
"update.package_hint": "이 사본은 {kind}(으)로 설치되었습니다. 새 패키지를 내려받은 뒤 다음 명령으로 설치하세요:",
"update.prefs_dev": "개발 빌드({version})이므로 업데이트를 확인하지 않습니다.",
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. SolidCraft는 직접 확인하지 않습니다.",
"update.prefs_flatpak": "이 사본({version})은 Flatpak이 업데이트합니다: {command}를 실행하거나 소프트웨어 센터를 사용하세요. OneCAD는 직접 확인하지 않습니다.",
"update.prefs_label": "업데이트",
"update.prefs_note": "SolidCraft {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
"update.prefs_note": "OneCAD {version} ({kind})입니다. 확인 시 GitHub에 최신 릴리스만 요청하며 다른 정보는 보내지 않습니다.",
"update.release_page": "릴리스 페이지 열기",
"update.restart": "지금 다시 시작",
"update.restart_later": "나중에",
@@ -1234,7 +1234,7 @@
"update.skip": "이 버전 건너뛰기",
"update.system_hint": "이 사본은 {kind}입니다. 다음 명령으로 업데이트하세요:",
"update.title": "업데이트 사용 가능",
"update.up_to_date": "SolidCraft가 최신 버전입니다.",
"update.up_to_date": "OneCAD가 최신 버전입니다.",
"update.update_now": "지금 업데이트",
"update.you_have": "현재 버전은 {version}입니다."
}
+25 -25
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. Clique em Submit new issue no GitHub para enviar o relatório.",
"A new configuration, a copy of the active one": "Uma nova configuração, cópia da ativa",
"AI": "IA",
"About SolidCraft": "Sobre o SolidCraft",
"About OneCAD": "Sobre o OneCAD",
"Active": "Ativo",
"Active rule": "Regra ativa",
"Add": "Adicionar",
@@ -239,7 +239,7 @@
"Sketch plane has no body-face attachment.": "O plano do esboço não está fixado a uma face de corpo.",
"Sketch points": "Pontos do esboço",
"Slot width": "Largura do rasgo",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "O SolidCraft fechou antes de estes projetos serem salvos. O último salvamento automático deles pode ser aberto.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "O OneCAD fechou antes de estes projetos serem salvos. O último salvamento automático deles pode ser aberto.",
"Speed": "Velocidade",
"Stand it up": "Colocar em pé",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Colocar em pé: um quarto de volta em torno de X, como um recurso Mover que pode ser desfeito.",
@@ -653,18 +653,18 @@
"home.report_issue": "Relatar um problema",
"home.samples": "Exemplos",
"home.whats_new": "Novidades",
"install.button_tip": "Instale o SolidCraft como aplicativo: ele abre em sua própria janela e funciona offline",
"install.button_tip": "Instale o OneCAD como aplicativo: ele abre em sua própria janela e funciona offline",
"install.dock_1": "Escolha Arquivo › Adicionar ao Dock.",
"install.dock_2": "Clique em Adicionar.",
"install.dock_after": "O SolidCraft passa a abrir em sua própria janela pelo Dock e funciona offline.",
"install.dock_after": "O OneCAD passa a abrir em sua própria janela pelo Dock e funciona offline.",
"install.done": "Concluído",
"install.home_screen_1": "Toque em Compartilhar (no menu … se não estiver na barra de ferramentas).",
"install.home_screen_2": "Escolha Adicionar à Tela de Início.",
"install.home_screen_3": "Toque em Adicionar.",
"install.home_screen_after": "O SolidCraft passa a abrir pelo ícone na Tela de Início, sem as barras do Safari, e funciona offline.",
"install.home_screen_after": "O OneCAD passa a abrir pelo ícone na Tela de Início, sem as barras do Safari, e funciona offline.",
"install.menu": "Instalar aplicativo…",
"install.steps_intro": "O Safari instala aplicativos web pelos próprios menus:",
"install.title": "Instalar o SolidCraft",
"install.title": "Instalar o OneCAD",
"joint.from_bodies_hint": "Cada corpo selecionado vai para um novo componente próprio e permanece onde está.",
"joint.one_body": "As duas seleções estão em {body}. Selecione a segunda em outra peça.",
"joint.one_component": "Estas peças são corpos de um mesmo componente: {bodies}. Uma junta move um componente em relação a outro, então cada peça precisa de seu próprio componente.",
@@ -744,10 +744,10 @@
"settings.frame_rate_note": "Mostra quadros por segundo no canto inferior direito da vista. Enquanto estiver ativo, a vista é redesenhada continuamente.",
"settings.measure_axis_distances": "Mostrar distâncias X/Y/Z",
"settings.measure_axis_distances_help": "Mostrar as componentes nos eixos do mundo junto com a distância total enquanto Medir está aberto.",
"settings.msaa_applies_on_restart": "O antisserrilhamento vale na próxima vez que o SolidCraft iniciar",
"settings.msaa_applies_on_restart": "O antisserrilhamento vale na próxima vez que o OneCAD iniciar",
"settings.msaa_reset": "{setting}: {requested}× não é suportado por este dispositivo gráfico; agora é {reset}× (suportados: {supported}).",
"settings.msaa_unsupported": "Não suportado por este dispositivo gráfico",
"settings.reset_intro": "Estas configurações não funcionam neste computador e impediriam o SolidCraft de iniciar, por isso foram alteradas:",
"settings.reset_intro": "Estas configurações não funcionam neste computador e impediriam o OneCAD de iniciar, por isso foram alteradas:",
"settings.reset_title": "As configurações foram alteradas",
"settings.supersampling_note": "Desenha a vista em resolução maior e a filtra para baixo: arestas, linhas e grade mais suaves em telas abaixo de 4K. Custa tempo de GPU: cerca de 2,25× os pixels em 1,5× e 4× em 2×.",
"sketch.degrees_of_freedom": "{count} graus de liberdade",
@@ -794,8 +794,8 @@
"ui.agent_chat_attach_view": "Anexar vista",
"ui.agent_chat_attach_view_tip": "Enviar uma imagem da vista atual com a mensagem",
"ui.agent_chat_auth_agent": "Entrar com {name}",
"ui.agent_chat_auth_env": "Defina {vars} no seu ambiente e reinicie o SolidCraft.",
"ui.agent_chat_auth_needed": "O agente pede que você entre. Você entra no próprio agente; o SolidCraft nunca vê sua senha ou chaves.",
"ui.agent_chat_auth_env": "Defina {vars} no seu ambiente e reinicie o OneCAD.",
"ui.agent_chat_auth_needed": "O agente pede que você entre. Você entra no próprio agente; o OneCAD nunca vê sua senha ou chaves.",
"ui.agent_chat_auth_none": "O agente não oferece forma de entrar aqui: entre do jeito descrito na documentação dele.",
"ui.agent_chat_auth_retry": "Já entrei: conectar de novo",
"ui.agent_chat_auth_terminal": "Execute isto em um terminal e depois pressione Conectar de novo:",
@@ -817,7 +817,7 @@
"ui.agent_chat_edit": "Editar",
"ui.agent_chat_exited": "O agente parou.",
"ui.agent_chat_failed_start": "O agente não pôde iniciar: {error}",
"ui.agent_chat_intro": "Converse com um agente de programação que você já usa, ou com um endpoint de modelo que você escolher. Ele trabalha no projeto aberto pelas ferramentas do SolidCraft: as alterações aparecem conforme são feitas e se desfazem em um passo por mensagem. O que ele pode fazer sem perguntar é definido em Preferências > IA.",
"ui.agent_chat_intro": "Converse com um agente de programação que você já usa, ou com um endpoint de modelo que você escolher. Ele trabalha no projeto aberto pelas ferramentas do OneCAD: as alterações aparecem conforme são feitas e se desfazem em um passo por mensagem. O que ele pode fazer sem perguntar é definido em Preferências > IA.",
"ui.agent_chat_key_in_env": "chave definida",
"ui.agent_chat_left": "Esquerda",
"ui.agent_chat_limit": "O agente atingiu o limite de {limit} alterações por mensagem. Envie outra mensagem para que ele continue.",
@@ -838,7 +838,7 @@
"ui.agent_chat_placeholder": "Peça ao agente para alterar o projeto…",
"ui.agent_chat_placeholder_busy": "Enfileirar uma mensagem para quando o agente terminar (Ctrl+Enter: enviar agora)",
"ui.agent_chat_privacy": "Nada vai para lugar algum além do agente que você executa, na sua própria conta.",
"ui.agent_chat_privacy_note": "O SolidCraft não envia nada a lugar nenhum: o agente que você executa fala com o próprio serviço, na sua própria conta. O SolidCraft nunca vê nem guarda chaves ou senhas.",
"ui.agent_chat_privacy_note": "O OneCAD não envia nada a lugar nenhum: o agente que você executa fala com o próprio serviço, na sua própria conta. O OneCAD nunca vê nem guarda chaves ou senhas.",
"ui.agent_chat_queue": "Fila",
"ui.agent_chat_queue_full": "Há mensagens demais esperando: remova uma ou aguarde o agente.",
"ui.agent_chat_queued": "Na fila",
@@ -877,7 +877,7 @@
"ui.ai_add_installed": "Agente instalado",
"ui.ai_add_installed_note": "Uma entrada própria para um agente instalado, por exemplo com outros argumentos. Os agentes instalados são listados por si mesmos.",
"ui.ai_added": "adicionado",
"ui.ai_agent_mode_tip": "O modo do próprio agente: as ferramentas dele (shell, edição de arquivos) o seguem. As ferramentas do SolidCraft seguem as permissões em Preferências > IA.",
"ui.ai_agent_mode_tip": "O modo do próprio agente: as ferramentas dele (shell, edição de arquivos) o seguem. As ferramentas do OneCAD seguem as permissões em Preferências > IA.",
"ui.ai_agents": "Agentes",
"ui.ai_always_allow": "Sempre permitir",
"ui.ai_always_allow_tip": "Permitir {tool} para este agente daqui em diante, sem perguntar",
@@ -885,7 +885,7 @@
"ui.ai_always_allowed_none": "Nada é sempre permitido ainda. As respostas \"Sempre permitir\" que você der no chat aparecem aqui.",
"ui.ai_auto_allowed": "Permitido automaticamente: {title} ({why})",
"ui.ai_auto_mode": "sua configuração de permissão",
"ui.ai_auto_ours": "o SolidCraft verifica as próprias ferramentas",
"ui.ai_auto_ours": "o OneCAD verifica as próprias ferramentas",
"ui.ai_auto_remembered": "você escolheu Sempre permitir",
"ui.ai_bad_url": "Digite uma URL que comece com http:// ou https://",
"ui.ai_base_url": "URL base",
@@ -899,7 +899,7 @@
"ui.ai_key": "Chave de API",
"ui.ai_key_hint": "opcional; guardada no chaveiro do sistema",
"ui.ai_key_needs_https": "Uma chave só é enviada por HTTPS ou para este computador.",
"ui.ai_key_note": "Guardada no chaveiro do sistema, nunca nas configurações do SolidCraft. Deixe em branco para um servidor local.",
"ui.ai_key_note": "Guardada no chaveiro do sistema, nunca nas configurações do OneCAD. Deixe em branco para um servidor local.",
"ui.ai_keychain_error": "O chaveiro do sistema recusou: {error}",
"ui.ai_mode_all": "Permitir tudo",
"ui.ai_mode_all_note": "Nada pergunta antes. Os relatórios de falha continuam fora de alcance e os limites de pasta ainda valem.",
@@ -910,7 +910,7 @@
"ui.ai_mode_edits_note": "Comandos de modelagem rodam sem perguntar; salvar, abrir, exportar e outras ações com arquivos ou destrutivas ainda perguntam.",
"ui.ai_mode_global": "Como definido acima",
"ui.ai_model": "Modelo",
"ui.ai_modes_scope": "Estas configurações valem para as ferramentas do SolidCraft. As ferramentas do próprio agente (shell, edição de arquivos) seguem o modo do agente, que você escolhe no cabeçalho do painel de chat quando o agente tem modos.",
"ui.ai_modes_scope": "Estas configurações valem para as ferramentas do OneCAD. As ferramentas do próprio agente (shell, edição de arquivos) seguem o modo do agente, que você escolhe no cabeçalho do painel de chat quando o agente tem modos.",
"ui.ai_name": "Nome",
"ui.ai_need_command": "Digite o comando que inicia o agente",
"ui.ai_need_model": "Digite o nome do modelo",
@@ -1425,31 +1425,31 @@
"ui.zoom": "Zoom",
"ui.zoom_to_cursor_tip": "A roda dá zoom em direção ao ponto sob o cursor (desativado: em direção ao centro da vista)",
"ui.zoom_to_the_cursor": "Zoom em direção ao cursor",
"update.available": "O SolidCraft {version} está disponível",
"update.available": "O OneCAD {version} está disponível",
"update.button": "Atualizar",
"update.button_tip": "O SolidCraft {version} está disponível",
"update.button_tip": "O OneCAD {version} está disponível",
"update.button_version": "Atualizar para {version}",
"update.check_automatically": "Verificar atualizações automaticamente",
"update.check_now": "Verificar agora",
"update.checking": "Verificando…",
"update.copy": "Copiar",
"update.dmg_opened": "A imagem de disco está aberta e verificada: arraste o SolidCraft para Aplicativos, substituindo a cópia antiga, e reinicie o SolidCraft.",
"update.dmg_opened": "A imagem de disco está aberta e verificada: arraste o OneCAD para Aplicativos, substituindo a cópia antiga, e reinicie o OneCAD.",
"update.download": "Baixar",
"update.download_open": "Baixar e abrir",
"update.downloading": "Baixando e verificando… {done} de {total} MB",
"update.failed": "Não foi possível verificar: {error}",
"update.file": "Baixar obtém {file}.",
"update.install_failed": "A atualização não foi instalada: {error}. Nada foi alterado; Baixar obtém o pacote em vez disso.",
"update.install_msi": "Instalar (fecha o SolidCraft)",
"update.installed": "O SolidCraft {version} está instalado. Reinicie para usá-lo.",
"update.installer_started": "O instalador começa quando o SolidCraft fechar.",
"update.install_msi": "Instalar (fecha o OneCAD)",
"update.installed": "O OneCAD {version} está instalado. Reinicie para usá-lo.",
"update.installer_started": "O instalador começa quando o OneCAD fechar.",
"update.later": "Lembrar mais tarde",
"update.no_notes": "Esta versão não tem notas.",
"update.package_hint": "Esta cópia veio de um {kind}. Baixe o novo pacote e instale-o com:",
"update.prefs_dev": "Esta é uma versão de desenvolvimento ({version}): ela não verifica atualizações.",
"update.prefs_flatpak": "O Flatpak atualiza esta cópia ({version}): execute {command} ou use sua central de programas. O SolidCraft não verifica.",
"update.prefs_flatpak": "O Flatpak atualiza esta cópia ({version}): execute {command} ou use sua central de programas. O OneCAD não verifica.",
"update.prefs_label": "Atualizações",
"update.prefs_note": "Este é o SolidCraft {version} ({kind}). Uma verificação pergunta ao GitHub pela versão mais recente e não envia mais nada.",
"update.prefs_note": "Este é o OneCAD {version} ({kind}). Uma verificação pergunta ao GitHub pela versão mais recente e não envia mais nada.",
"update.release_page": "Abrir página da versão",
"update.restart": "Reiniciar agora",
"update.restart_later": "Depois",
@@ -1457,7 +1457,7 @@
"update.skip": "Pular esta versão",
"update.system_hint": "Esta cópia é um {kind}. Atualize-a com:",
"update.title": "Atualização disponível",
"update.up_to_date": "O SolidCraft está atualizado.",
"update.up_to_date": "O OneCAD está atualizado.",
"update.update_now": "Atualizar agora",
"update.you_have": "Você tem a {version}."
}
+8 -8
View File
@@ -1,7 +1,7 @@
{
"0 turns it off": "0 отключает",
"A new configuration, a copy of the active one": "Новая конфигурация — копия активной",
"About SolidCraft": "О SolidCraft",
"About OneCAD": "О OneCAD",
"Acceleration": "Ускорение",
"Active": "Активный",
"Active rule": "Активное правило",
@@ -212,7 +212,7 @@
"Sketch geometry and profiles": "Геометрия и профили эскиза",
"Sketch plane has no body-face attachment.": "Плоскость эскиза не привязана к грани тела.",
"Sketch points": "Точки эскиза",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрылся до сохранения этих дизайнов. Можно открыть последнюю автосохранённую версию.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD закрылся до сохранения этих дизайнов. Можно открыть последнюю автосохранённую версию.",
"Speed": "Скорость",
"Stand it up": "Поставить вертикально",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Поставить вертикально: четверть оборота вокруг X одной операцией «Переместить», которую можно отменить.",
@@ -620,18 +620,18 @@
"home.report_issue": "Сообщить о проблеме",
"home.samples": "Примеры",
"home.whats_new": "Что нового",
"install.button_tip": "Установить SolidCraft как приложение: отдельное окно и работа без сети",
"install.button_tip": "Установить OneCAD как приложение: отдельное окно и работа без сети",
"install.dock_1": "Выберите «Файл» › «Добавить в Dock».",
"install.dock_2": "Нажмите «Добавить».",
"install.dock_after": "После этого SolidCraft открывается из Dock в отдельном окне и работает без сети.",
"install.dock_after": "После этого OneCAD открывается из Dock в отдельном окне и работает без сети.",
"install.done": "Готово",
"install.home_screen_1": "Нажмите «Поделиться» (в меню …, если кнопки нет на панели).",
"install.home_screen_2": "Выберите «На экран „Домой“».",
"install.home_screen_3": "Нажмите «Добавить».",
"install.home_screen_after": "После этого SolidCraft открывается значком на экране «Домой», без панелей Safari, и работает без сети.",
"install.home_screen_after": "После этого OneCAD открывается значком на экране «Домой», без панелей Safari, и работает без сети.",
"install.menu": "Установить приложение…",
"install.steps_intro": "Safari устанавливает веб-приложения через собственные меню:",
"install.title": "Установка SolidCraft",
"install.title": "Установка OneCAD",
"joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте.",
"joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.",
"joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.",
@@ -707,10 +707,10 @@
"sample.vents.name": "Вентиляционная пластина",
"settings.measure_axis_distances": "Расстояния X/Y/Z",
"settings.measure_axis_distances_help": "Показывать размеры вдоль мировых осей вместе с общим расстоянием, пока открыто окно измерения.",
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске SolidCraft",
"settings.msaa_applies_on_restart": "Сглаживание вступит в силу при следующем запуске OneCAD",
"settings.msaa_reset": "{setting}: {requested}× не поддерживается этим графическим устройством; теперь {reset}× (поддерживается: {supported}).",
"settings.msaa_unsupported": "Не поддерживается этим графическим устройством",
"settings.reset_intro": "Эти настройки не работают на этом компьютере и могли бы помешать запуску SolidCraft, поэтому они были изменены:",
"settings.reset_intro": "Эти настройки не работают на этом компьютере и могли бы помешать запуску OneCAD, поэтому они были изменены:",
"settings.reset_title": "Настройки были изменены",
"sketch.degrees_of_freedom": "Степеней свободы: {count}",
"sketch.draw_curves": "Рисовать как {linetype}",
+25 -25
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. Raporu göndermek için GitHub'da Submit new issue düğmesine tıklayın.",
"A new configuration, a copy of the active one": "Etkin konfigürasyonun kopyası olan yeni bir konfigürasyon",
"AI": "AI",
"About SolidCraft": "SolidCraft hakkında",
"About OneCAD": "OneCAD hakkında",
"Acceleration": "İvme",
"Active": "Etkin",
"Active rule": "Etkin kural",
@@ -230,7 +230,7 @@
"Sketch plane has no body-face attachment.": "Eskiz düzleminin bir gövde yüzüne bağlaması yok.",
"Sketch points": "Eskiz noktaları",
"Slot width": "Yuva genişliği",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft bu tasarımlar kaydedilmeden önce kapandı. Son otomatik kayıtları açılabilir.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD bu tasarımlar kaydedilmeden önce kapandı. Son otomatik kayıtları açılabilir.",
"Speed": "Hız",
"Stand it up": "Dik çevir",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Dik çevir: X etrafında çeyrek tur; geri alınabilen tek bir Taşı özelliği olarak eklenir.",
@@ -619,18 +619,18 @@
"home.report_issue": "Sorun bildir",
"home.samples": "Örnekler",
"home.whats_new": "Yenilikler",
"install.button_tip": "SolidCraft'ı uygulama olarak yükleyin: kendi penceresinde açılır ve çevrimdışı da çalışır",
"install.button_tip": "OneCAD'ı uygulama olarak yükleyin: kendi penceresinde açılır ve çevrimdışı da çalışır",
"install.dock_1": "Dosya › Dock'a Ekle'yi seçin.",
"install.dock_2": "“Ekle” düğmesine tıklayın.",
"install.dock_after": "SolidCraft daha sonra Dock'tan kendi penceresinde açılır ve çevrimdışı da çalışır.",
"install.dock_after": "OneCAD daha sonra Dock'tan kendi penceresinde açılır ve çevrimdışı da çalışır.",
"install.done": "Bitti",
"install.home_screen_1": "“Paylaş” düğmesine dokunun (araç çubuğunda değilse … menüsünde).",
"install.home_screen_2": "“Ana Ekrana Ekle” seçeneğini seçin.",
"install.home_screen_3": "“Ekle” düğmesine dokunun.",
"install.home_screen_after": "SolidCraft daha sonra ana ekrandaki simgesinden, Safari çubukları olmadan açılır ve çevrimdışı da çalışır.",
"install.home_screen_after": "OneCAD daha sonra ana ekrandaki simgesinden, Safari çubukları olmadan açılır ve çevrimdışı da çalışır.",
"install.menu": "Uygulamayı yükle…",
"install.steps_intro": "Safari web uygulamalarını kendi menüleri üzerinden yükler:",
"install.title": "SolidCraft'ı yükle",
"install.title": "OneCAD'ı yükle",
"joint.from_bodies_hint": "Seçilen her gövde kendi yeni bileşenine konur ve bulunduğu yerde kalır.",
"joint.one_body": "Her iki seçim de {body} üzerinde. İkincisini başka bir parçada seçin.",
"joint.one_component": "Bu parçalar tek bir bileşenin gövdeleridir: {bodies}. Bir bağlantı bir bileşeni diğerine göre hareket ettirir, bu nedenle her parçanın kendi bileşeni olması gerekir.",
@@ -711,10 +711,10 @@
"sample.vents.about": "Dikdörtgen desende yuvalar: satırlar, sütunlar ve aralık parametredir",
"sample.vents.name": "Havalandırma plakası",
"settings.display_quality_note": "Daha ince seviyeler eğri yüzeyleri ve kenarları daha pürüzsüz gösterir, ancak ağ oluşturma daha uzun sürer. Yalnızca görünüm değişir; tasarım ve dışa aktarımlar asla değişmez.",
"settings.msaa_applies_on_restart": "Kenar yumuşatma, SolidCraft bir sonraki başlatıldığında uygulanır",
"settings.msaa_applies_on_restart": "Kenar yumuşatma, OneCAD bir sonraki başlatıldığında uygulanır",
"settings.msaa_reset": "{setting}: {requested}× bu grafik aygıtı tarafından desteklenmiyor; {reset}× olarak ayarlandı (desteklenen: {supported}).",
"settings.msaa_unsupported": "Bu grafik aygıtı tarafından desteklenmiyor",
"settings.reset_intro": "Bu ayarlar bu bilgisayarda çalışmadığı ve SolidCraft'ın başlamasını engelleyeceği için değiştirildi:",
"settings.reset_intro": "Bu ayarlar bu bilgisayarda çalışmadığı ve OneCAD'ın başlamasını engelleyeceği için değiştirildi:",
"settings.reset_title": "Ayarlar değiştirildi",
"settings.supersampling_note": "Görünümü daha yüksek çözünürlükte çizer ve küçültür: 4K altındaki ekranlarda kenarlar, çizgiler ve ızgaralar daha pürüzsüz görünür. GPU süresi gerektirir: 1,5× ayarında yaklaşık 2,25 kat, 2× ayarında 4 kat daha fazla piksel işlenir.",
"sketch.degrees_of_freedom": "{count} serbestlik derecesi",
@@ -758,8 +758,8 @@
"ui.agent_chat_attach_view": "Görünümü ekle",
"ui.agent_chat_attach_view_tip": "Geçerli görünümün bir resmini mesajla birlikte gönder",
"ui.agent_chat_auth_agent": "{name} ile oturum aç",
"ui.agent_chat_auth_env": "{vars} ortam değişkenlerini ayarlayın ve SolidCraft'ı yeniden başlatın.",
"ui.agent_chat_auth_needed": "Ajan oturum açmanızı istiyor. Oturumu ajanın kendisinde açarsınız; SolidCraft parolanızı veya anahtarlarınızı asla görmez.",
"ui.agent_chat_auth_env": "{vars} ortam değişkenlerini ayarlayın ve OneCAD'ı yeniden başlatın.",
"ui.agent_chat_auth_needed": "Ajan oturum açmanızı istiyor. Oturumu ajanın kendisinde açarsınız; OneCAD parolanızı veya anahtarlarınızı asla görmez.",
"ui.agent_chat_auth_none": "Ajan burada oturum açma imkânı sunmuyor: Belgelerinde açıklandığı şekilde oturum açın.",
"ui.agent_chat_auth_retry": "Oturum açtım: yeniden bağlan",
"ui.agent_chat_auth_terminal": "Bunu bir terminalde çalıştırın ve ardından tekrar Bağlan'a tıklayın:",
@@ -780,7 +780,7 @@
"ui.agent_chat_dock_right": "Sağa yerleştir",
"ui.agent_chat_exited": "Ajan sonlandırıldı.",
"ui.agent_chat_failed_start": "Ajan başlatılamadı: {error}",
"ui.agent_chat_intro": "Zaten kullandığınız bir kodlama ajanıyla veya seçtiğiniz bir model uç noktasıyla sohbet edin. Ajan, SolidCraft'ın araçlarıyla açık tasarım üzerinde çalışır: Değişiklikleri hemen görünür ve mesaj başına tek adımda geri alınabilir. Sormadan neleri yapabileceğini Ayarlar > AI altında belirlersiniz.",
"ui.agent_chat_intro": "Zaten kullandığınız bir kodlama ajanıyla veya seçtiğiniz bir model uç noktasıyla sohbet edin. Ajan, OneCAD'ın araçlarıyla açık tasarım üzerinde çalışır: Değişiklikleri hemen görünür ve mesaj başına tek adımda geri alınabilir. Sormadan neleri yapabileceğini Ayarlar > AI altında belirlersiniz.",
"ui.agent_chat_key_in_env": "anahtar ayarlı",
"ui.agent_chat_left": "Sol",
"ui.agent_chat_limit": "Ajan, bir mesaj için {limit} değişiklik sınırına ulaştı. Devam etmesi için başka bir mesaj gönderin.",
@@ -796,7 +796,7 @@
"ui.agent_chat_pick": "Ajan",
"ui.agent_chat_placeholder": "Ajandan tasarımı değiştirmesini isteyin…",
"ui.agent_chat_privacy": "Kendi hesabınızla çalıştırdığınız ajan dışında hiçbir yere bir şey gitmez.",
"ui.agent_chat_privacy_note": "SolidCraft hiçbir yere bir şey göndermez: Başlattığınız ajan, sizin hesabınızla kendi hizmetine bağlanır. SolidCraft hiçbir anahtarı veya parolayı görmez ve kaydetmez.",
"ui.agent_chat_privacy_note": "OneCAD hiçbir yere bir şey göndermez: Başlattığınız ajan, sizin hesabınızla kendi hizmetine bağlanır. OneCAD hiçbir anahtarı veya parolayı görmez ve kaydetmez.",
"ui.agent_chat_refresh": "Yenile",
"ui.agent_chat_refused": "Ajan için izin verilmiyor: {reason}",
"ui.agent_chat_right": "Sağ",
@@ -826,7 +826,7 @@
"ui.ai_add_installed": "Yüklü ajan",
"ui.ai_add_installed_note": "Yüklü bir ajan için ayrı bir giriş (örneğin farklı argümanlarla). Yüklü ajanlar kendiliğinden görünür.",
"ui.ai_added": "eklendi",
"ui.ai_agent_mode_tip": "Ajanın kendi modu: Kendi araçları (kabuk, dosya değişiklikleri) bu modu izler. SolidCraft'ın araçları Ayarlar > AI altındaki izinleri izler.",
"ui.ai_agent_mode_tip": "Ajanın kendi modu: Kendi araçları (kabuk, dosya değişiklikleri) bu modu izler. OneCAD'ın araçları Ayarlar > AI altındaki izinleri izler.",
"ui.ai_agents": "Ajanlar",
"ui.ai_always_allow": "Her zaman izin ver",
"ui.ai_always_allow_tip": "{tool} için bu ajana bundan sonra sormadan izin ver",
@@ -834,7 +834,7 @@
"ui.ai_always_allowed_none": "Henüz her zaman izin verilen bir şey yok. Sohbette verdiğiniz “Her zaman izin ver” yanıtları burada görünür.",
"ui.ai_auto_allowed": "{title} için otomatik olarak izin verildi ({why})",
"ui.ai_auto_mode": "İzin ayarınız",
"ui.ai_auto_ours": "SolidCraft kendi araçlarını kendisi denetler",
"ui.ai_auto_ours": "OneCAD kendi araçlarını kendisi denetler",
"ui.ai_auto_remembered": "“Her zaman izin ver” seçeneğini seçtiniz",
"ui.ai_bad_url": "http:// veya https:// ile başlayan bir URL girin",
"ui.ai_base_url": "Temel URL",
@@ -848,7 +848,7 @@
"ui.ai_key": "API anahtarı",
"ui.ai_key_hint": "isteğe bağlı; sistem anahtar zincirinde saklanır",
"ui.ai_key_needs_https": "Anahtar yalnızca HTTPS üzerinden veya bu bilgisayara gönderilir.",
"ui.ai_key_note": "SolidCraft ayarlarında değil, sistem anahtar zincirinde saklanır. Yerel bir sunucu için boş bırakın.",
"ui.ai_key_note": "OneCAD ayarlarında değil, sistem anahtar zincirinde saklanır. Yerel bir sunucu için boş bırakın.",
"ui.ai_keychain_error": "Sistem anahtar zinciri reddetti: {error}",
"ui.ai_mode_all": "Her şeye izin ver",
"ui.ai_mode_all_note": "Hiçbir işlem için önceden onay istenmez. Çökme raporlarına yine erişilemez ve klasör sınırları geçerliliğini korur.",
@@ -859,7 +859,7 @@
"ui.ai_mode_edits_note": "Modelleme komutları onay istemeden çalışır; kaydetme, açma, dışa aktarma ve diğer dosya ya da yıkıcı işlemler için yine onay istenir.",
"ui.ai_mode_global": "Yukarıda ayarlandığı gibi",
"ui.ai_model": "Model",
"ui.ai_modes_scope": "Bu ayarlar SolidCraft araçları için geçerlidir. Bir ajanın kendi araçları (kabuğu, dosya değişiklikleri), ajanın modları varsa sohbet panelinin başlığında seçtiğiniz kendi modunu izler.",
"ui.ai_modes_scope": "Bu ayarlar OneCAD araçları için geçerlidir. Bir ajanın kendi araçları (kabuğu, dosya değişiklikleri), ajanın modları varsa sohbet panelinin başlığında seçtiğiniz kendi modunu izler.",
"ui.ai_name": "Ad",
"ui.ai_need_command": "Ajanı başlatan komutu girin",
"ui.ai_need_model": "Model adını girin",
@@ -1372,30 +1372,30 @@
"ui.zoom": "Yakınlaştır",
"ui.zoom_to_cursor_tip": "Tekerlek, imlecin altındaki noktaya doğru yakınlaştırır (kapalı: görünümün merkezine doğru)",
"ui.zoom_to_the_cursor": "İmlece doğru yakınlaştır",
"update.available": "SolidCraft {version} kullanılabilir",
"update.available": "OneCAD {version} kullanılabilir",
"update.button": "Güncelle",
"update.button_tip": "SolidCraft {version} kullanılabilir",
"update.button_tip": "OneCAD {version} kullanılabilir",
"update.check_automatically": "Güncellemeleri otomatik denetle",
"update.check_now": "Şimdi denetle",
"update.checking": "Denetleniyor…",
"update.copy": "Kopyala",
"update.dmg_opened": "Disk görüntüsü açıldı ve doğrulandı: SolidCraft'ı “Uygulamalar” klasörüne sürükleyin, eski sürümü değiştirin ve SolidCraft'ı yeniden başlatın.",
"update.dmg_opened": "Disk görüntüsü açıldı ve doğrulandı: OneCAD'ı “Uygulamalar” klasörüne sürükleyin, eski sürümü değiştirin ve OneCAD'ı yeniden başlatın.",
"update.download": "İndir",
"update.download_open": "İndir ve aç",
"update.downloading": "İndiriliyor ve doğrulanıyor… {done} / {total} MB",
"update.failed": "Denetim başarısız: {error}",
"update.file": "“İndir” düğmesi {file} dosyasını indirir.",
"update.install_failed": "Güncelleme yüklenmedi: {error}. Hiçbir şey değiştirilmedi; paketi “İndir” ile indirebilirsiniz.",
"update.install_msi": "Yükle (SolidCraft'ı kapatır)",
"update.installed": "SolidCraft {version} yüklendi. Kullanmak için yeniden başlatın.",
"update.installer_started": "Yükleme programı, SolidCraft kapatıldığında başlar.",
"update.install_msi": "Yükle (OneCAD'ı kapatır)",
"update.installed": "OneCAD {version} yüklendi. Kullanmak için yeniden başlatın.",
"update.installer_started": "Yükleme programı, OneCAD kapatıldığında başlar.",
"update.later": "Daha sonra hatırlat",
"update.no_notes": "Bu sürüm için not bulunmuyor.",
"update.package_hint": "Bu kurulum bir {kind} kaynağından geliyor. Yeni paketi indirip şu komutla yükleyin:",
"update.prefs_dev": "Bu bir geliştirme derlemesi ({version}): güncelleme aramaz.",
"update.prefs_flatpak": "Bu kurulum ({version}) Flatpak tarafından güncellenir: {command} komutunu çalıştırın veya yazılım yöneticisini kullanın. SolidCraft kendisi denetlemez.",
"update.prefs_flatpak": "Bu kurulum ({version}) Flatpak tarafından güncellenir: {command} komutunu çalıştırın veya yazılım yöneticisini kullanın. OneCAD kendisi denetlemez.",
"update.prefs_label": "Güncellemeler",
"update.prefs_note": "Bu, SolidCraft {version} ({kind}). Denetim, GitHub'a en yeni sürümü sorar ve başka hiçbir şey göndermez.",
"update.prefs_note": "Bu, OneCAD {version} ({kind}). Denetim, GitHub'a en yeni sürümü sorar ve başka hiçbir şey göndermez.",
"update.release_page": "Sürüm sayfasını aç",
"update.restart": "Şimdi yeniden başlat",
"update.restart_later": "Daha sonra",
@@ -1403,7 +1403,7 @@
"update.skip": "Bu sürümü atla",
"update.system_hint": "Bu kurulum bir {kind}. Şu komutla güncelleyin:",
"update.title": "Güncelleme mevcut",
"update.up_to_date": "SolidCraft güncel.",
"update.up_to_date": "OneCAD güncel.",
"update.update_now": "Şimdi güncelle",
"update.you_have": "Yüklü sürüm: {version}."
}
+25 -25
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. Натисніть Submit new issue на GitHub, щоб надіслати звіт.",
"A new configuration, a copy of the active one": "Нова конфігурація, копія активної",
"AI": "ШІ",
"About SolidCraft": "Про SolidCraft",
"About OneCAD": "Про OneCAD",
"Acceleration": "Прискорення",
"Active": "Активна",
"Active rule": "Активне правило",
@@ -248,7 +248,7 @@
"Sketch plane has no body-face attachment.": "Площина ескізу не має прив'язки до грані тіла.",
"Sketch points": "Точки ескізу",
"Slot width": "Ширина паза",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft закрився до того, як ці проєкти було збережено. Можна відкрити їхнє останнє автозбереження.",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD закрився до того, як ці проєкти було збережено. Можна відкрити їхнє останнє автозбереження.",
"Speed": "Швидкість",
"Stand it up": "Вирівняти",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "Вирівняти: поворот на чверть оберту навколо X як одна операція переміщення, яку можна скасувати.",
@@ -672,18 +672,18 @@
"home.report_issue": "Повідомити про проблему",
"home.samples": "Зразки",
"home.whats_new": "Що нового",
"install.button_tip": "Встановити SolidCraft як застосунок: він відкривається в окремому вікні й працює без мережі",
"install.button_tip": "Встановити OneCAD як застосунок: він відкривається в окремому вікні й працює без мережі",
"install.dock_1": "Виберіть «Файл» › «Додати в Dock».",
"install.dock_2": "Натисніть «Додати».",
"install.dock_after": "Після цього SolidCraft відкривається з Dock в окремому вікні й працює без мережі.",
"install.dock_after": "Після цього OneCAD відкривається з Dock в окремому вікні й працює без мережі.",
"install.done": "Готово",
"install.home_screen_1": "Торкніться «Поширити» (у меню …, якщо цієї кнопки немає на панелі).",
"install.home_screen_2": "Виберіть «На початковий екран».",
"install.home_screen_3": "Торкніться «Додати».",
"install.home_screen_after": "Після цього SolidCraft відкривається значком на початковому екрані, без панелей Safari, і працює без мережі.",
"install.home_screen_after": "Після цього OneCAD відкривається значком на початковому екрані, без панелей Safari, і працює без мережі.",
"install.menu": "Встановити застосунок…",
"install.steps_intro": "Safari встановлює вебзастосунки через власні меню:",
"install.title": "Встановлення SolidCraft",
"install.title": "Встановлення OneCAD",
"joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці.",
"joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.",
"joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.",
@@ -767,10 +767,10 @@
"settings.frame_rate_note": "Показує кадри за секунду в правому нижньому куті виду. Поки це ввімкнено, вид перемальовується безперервно.",
"settings.measure_axis_distances": "Відстані X/Y/Z",
"settings.measure_axis_distances_help": "Показувати розміри вздовж світових осей разом із загальною відстанню, поки відкрите вікно вимірювання.",
"settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску SolidCraft",
"settings.msaa_applies_on_restart": "Згладжування застосується під час наступного запуску OneCAD",
"settings.msaa_reset": "{setting}: {requested}× не підтримується цим графічним пристроєм; тепер це {reset}× (підтримується: {supported}).",
"settings.msaa_unsupported": "Не підтримується цим графічним пристроєм",
"settings.reset_intro": "Ці налаштування не працюють на цьому комп'ютері й не давали б SolidCraft запуститися, тому їх змінено:",
"settings.reset_intro": "Ці налаштування не працюють на цьому комп'ютері й не давали б OneCAD запуститися, тому їх змінено:",
"settings.reset_title": "Налаштування було змінено",
"settings.supersampling_note": "Малює вид у вищій роздільності й потім зменшує його: плавніші ребра, лінії та сітка на екранах до 4K. Це коштує часу GPU: приблизно 2,25× пікселів за 1,5× і 4× за 2×.",
"sketch.degrees_of_freedom": "{count} ступенів вільності",
@@ -817,8 +817,8 @@
"ui.agent_chat_attach_view": "Прикріпити вид",
"ui.agent_chat_attach_view_tip": "Надіслати знімок поточного виду разом із повідомленням",
"ui.agent_chat_auth_agent": "Увійти через {name}",
"ui.agent_chat_auth_env": "Задайте {vars} у своєму середовищі та перезапустіть SolidCraft.",
"ui.agent_chat_auth_needed": "Агент просить вас увійти. Ви входите через сам агент; SolidCraft ніколи не бачить вашого пароля чи ключів.",
"ui.agent_chat_auth_env": "Задайте {vars} у своєму середовищі та перезапустіть OneCAD.",
"ui.agent_chat_auth_needed": "Агент просить вас увійти. Ви входите через сам агент; OneCAD ніколи не бачить вашого пароля чи ключів.",
"ui.agent_chat_auth_none": "Агент тут не пропонує способу входу: увійдіть так, як описано в його власній документації.",
"ui.agent_chat_auth_retry": "Я ввійшов: під'єднатися знову",
"ui.agent_chat_auth_terminal": "Виконайте це в терміналі, а потім знову натисніть «Під'єднатися»:",
@@ -840,7 +840,7 @@
"ui.agent_chat_edit": "Редагувати",
"ui.agent_chat_exited": "Агент зупинився.",
"ui.agent_chat_failed_start": "Не вдалося запустити агента: {error}",
"ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти SolidCraft: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.",
"ui.agent_chat_intro": "Спілкуйтеся з агентом для програмування, яким уже користуєтеся, або з обраною вами модельною кінцевою точкою. Він працює з відкритим проєктом через інструменти OneCAD: його зміни з'являються в міру виконання й скасовуються одним кроком на повідомлення. Що він може робити без запиту, задано в Налаштування > ШІ.",
"ui.agent_chat_key_in_env": "ключ задано",
"ui.agent_chat_left": "Ліворуч",
"ui.agent_chat_limit": "Агент досяг межі {limit} змін на одне повідомлення. Надішліть інше повідомлення, щоб дозволити йому продовжити.",
@@ -861,7 +861,7 @@
"ui.agent_chat_placeholder": "Попросіть агента змінити проєкт…",
"ui.agent_chat_placeholder_busy": "Поставте повідомлення в чергу, доки агент завершить (Ctrl+Enter: надіслати зараз)",
"ui.agent_chat_privacy": "Нічого нікуди не надсилається, крім агента, який ви запускаєте, під вашим власним обліковим записом.",
"ui.agent_chat_privacy_note": "SolidCraft нічого нікуди не надсилає: агент, який ви запускаєте, спілкується з власним сервісом під вашим обліковим записом. SolidCraft ніколи не бачить і не зберігає ключ чи пароль.",
"ui.agent_chat_privacy_note": "OneCAD нічого нікуди не надсилає: агент, який ви запускаєте, спілкується з власним сервісом під вашим обліковим записом. OneCAD ніколи не бачить і не зберігає ключ чи пароль.",
"ui.agent_chat_queue": "У чергу",
"ui.agent_chat_queue_full": "Забагато повідомлень в очікуванні: приберіть одне або зачекайте на агента.",
"ui.agent_chat_queued": "У черзі",
@@ -900,7 +900,7 @@
"ui.ai_add_installed": "Встановлений агент",
"ui.ai_add_installed_note": "Ваш власний запис для встановленого агента, наприклад з іншими аргументами. Встановлені агенти перелічуються окремо.",
"ui.ai_added": "додано",
"ui.ai_agent_mode_tip": "Власний режим агента: його власні інструменти (оболонка, редагування файлів) підкоряються йому. Інструменти SolidCraft підкоряються дозволам у Налаштування > ШІ.",
"ui.ai_agent_mode_tip": "Власний режим агента: його власні інструменти (оболонка, редагування файлів) підкоряються йому. Інструменти OneCAD підкоряються дозволам у Налаштування > ШІ.",
"ui.ai_agents": "Агенти",
"ui.ai_always_allow": "Завжди дозволяти",
"ui.ai_always_allow_tip": "Дозволяти {tool} для цього агента відтепер без запиту",
@@ -908,7 +908,7 @@
"ui.ai_always_allowed_none": "Поки нічого не дозволено завжди. Відповіді «Завжди дозволяти», які ви даєте в чаті, перелічуються тут.",
"ui.ai_auto_allowed": "Автоматично дозволено: {title} ({why})",
"ui.ai_auto_mode": "ваше налаштування дозволів",
"ui.ai_auto_ours": "SolidCraft сам перевіряє власні інструменти",
"ui.ai_auto_ours": "OneCAD сам перевіряє власні інструменти",
"ui.ai_auto_remembered": "ви вибрали «Завжди дозволяти»",
"ui.ai_bad_url": "Введіть URL-адресу, що починається з http:// або https://",
"ui.ai_base_url": "Базова URL-адреса",
@@ -922,7 +922,7 @@
"ui.ai_key": "Ключ API",
"ui.ai_key_hint": "необов'язково; зберігається в системному сховищі ключів",
"ui.ai_key_needs_https": "Ключ надсилається лише через HTTPS або на цей комп'ютер.",
"ui.ai_key_note": "Зберігається в системному сховищі ключів, ніколи в налаштуваннях SolidCraft. Залиште порожнім для локального сервера.",
"ui.ai_key_note": "Зберігається в системному сховищі ключів, ніколи в налаштуваннях OneCAD. Залиште порожнім для локального сервера.",
"ui.ai_keychain_error": "Системне сховище ключів відмовило: {error}",
"ui.ai_mode_all": "Дозволяти все",
"ui.ai_mode_all_note": "Ніщо не запитує заздалегідь. Звіти про збої залишаються поза межами, а обмеження тек залишаються чинними.",
@@ -933,7 +933,7 @@
"ui.ai_mode_edits_note": "Команди моделювання виконуються без запиту; збереження, відкриття, експорт та інші файлові чи руйнівні дії все ще запитують.",
"ui.ai_mode_global": "Як задано вище",
"ui.ai_model": "Модель",
"ui.ai_modes_scope": "Ці налаштування стосуються інструментів SolidCraft. Власні інструменти агента (його оболонка, його редагування файлів) підкоряються власному режиму агента, який ви вибираєте в заголовку панелі чату, коли агент має режими.",
"ui.ai_modes_scope": "Ці налаштування стосуються інструментів OneCAD. Власні інструменти агента (його оболонка, його редагування файлів) підкоряються власному режиму агента, який ви вибираєте в заголовку панелі чату, коли агент має режими.",
"ui.ai_name": "Назва",
"ui.ai_need_command": "Введіть команду, що запускає агента",
"ui.ai_need_model": "Введіть назву моделі",
@@ -1448,31 +1448,31 @@
"ui.zoom": "Масштабувати",
"ui.zoom_to_cursor_tip": "Колесо масштабує до точки під курсором (вимкнено: до центру виду)",
"ui.zoom_to_the_cursor": "Масштабувати до курсора",
"update.available": "Доступна версія SolidCraft {version}",
"update.available": "Доступна версія OneCAD {version}",
"update.button": "Оновити",
"update.button_tip": "Доступна версія SolidCraft {version}",
"update.button_tip": "Доступна версія OneCAD {version}",
"update.button_version": "Оновити до {version}",
"update.check_automatically": "Автоматично перевіряти оновлення",
"update.check_now": "Перевірити зараз",
"update.checking": "Перевірка…",
"update.copy": "Копіювати",
"update.dmg_opened": "Образ диска відкрито й перевірено: перетягніть SolidCraft до Applications, замінивши стару копію, а потім перезапустіть SolidCraft.",
"update.dmg_opened": "Образ диска відкрито й перевірено: перетягніть OneCAD до Applications, замінивши стару копію, а потім перезапустіть OneCAD.",
"update.download": "Завантажити",
"update.download_open": "Завантажити й відкрити",
"update.downloading": "Завантаження та перевірка… {done} з {total} МБ",
"update.failed": "Не вдалося перевірити: {error}",
"update.file": "Завантаження отримає {file}.",
"update.install_failed": "Оновлення не встановлено: {error}. Нічого не змінено; натомість «Завантажити» отримає пакет.",
"update.install_msi": "Установити (закриє SolidCraft)",
"update.installed": "SolidCraft {version} установлено. Перезапустіть, щоб скористатися.",
"update.installer_started": "Інсталятор запуститься, коли SolidCraft закриється.",
"update.install_msi": "Установити (закриє OneCAD)",
"update.installed": "OneCAD {version} установлено. Перезапустіть, щоб скористатися.",
"update.installer_started": "Інсталятор запуститься, коли OneCAD закриється.",
"update.later": "Нагадати пізніше",
"update.no_notes": "Цей випуск не має приміток.",
"update.package_hint": "Ця копія походить з {kind}. Завантажте новий пакет, а потім установіть його за допомогою:",
"update.prefs_dev": "Це збірка для розробки ({version}): вона не перевіряє оновлення.",
"update.prefs_flatpak": "Flatpak оновлює цю копію ({version}): виконайте {command} або скористайтеся центром програм. SolidCraft не перевіряє.",
"update.prefs_flatpak": "Flatpak оновлює цю копію ({version}): виконайте {command} або скористайтеся центром програм. OneCAD не перевіряє.",
"update.prefs_label": "Оновлення",
"update.prefs_note": "Це SolidCraft {version} ({kind}). Перевірка запитує в GitHub останній випуск і більше нічого не надсилає.",
"update.prefs_note": "Це OneCAD {version} ({kind}). Перевірка запитує в GitHub останній випуск і більше нічого не надсилає.",
"update.release_page": "Відкрити сторінку випуску",
"update.restart": "Перезапустити зараз",
"update.restart_later": "Пізніше",
@@ -1480,7 +1480,7 @@
"update.skip": "Пропустити цю версію",
"update.system_hint": "Ця копія — {kind}. Оновіть її за допомогою:",
"update.title": "Доступне оновлення",
"update.up_to_date": "SolidCraft оновлено.",
"update.up_to_date": "OneCAD оновлено.",
"update.update_now": "Оновити зараз",
"update.you_have": "У вас версія {version}."
}
+32 -25
View File
@@ -6,7 +6,7 @@
"3. Click Submit new issue on GitHub to send your report.": "3. 在 GitHub 上点击 Submit new issue 发送报告。",
"A new configuration, a copy of the active one": "新建配置,为当前配置的副本",
"AI": "AI",
"About SolidCraft": "关于 SolidCraft",
"About OneCAD": "关于 OneCAD",
"Acceleration": "加速度",
"Active": "活动",
"Active rule": "活动规则",
@@ -31,6 +31,7 @@
"Arc length": "弧长",
"Area": "面积",
"Attachment": "附着",
"BOUNDARY SURFACE": "边界曲面",
"BROWSER": "浏览器",
"Bend Line Position": "弯折线位置",
"Bend Position": "折弯位置",
@@ -73,6 +74,7 @@
"Counterbore depth": "沉孔深度",
"Countersink angle": "沉头角度",
"Create Issue — review draft": "创建 Issue——审阅草稿",
"Cross curves": "交叉曲线",
"Cube": "立方体",
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
"Current perspective view": "当前透视视图",
@@ -252,7 +254,7 @@
"Sketch plane has no body-face attachment.": "草图平面没有实体面附着。",
"Sketch points": "草图点",
"Slot width": "槽宽",
"SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft 在这些设计保存前已关闭。可以打开它们的上次自动保存。",
"OneCAD closed before these designs were saved. Their last autosave can be opened.": "OneCAD 在这些设计保存前已关闭。可以打开它们的上次自动保存。",
"Speed": "速度",
"Stand it up": "将其立起",
"Stand it up: a quarter turn about X, as one Move feature you can undo.": "将其立起:绕 X 轴旋转四分之一周,作为一个可撤销的移动特征。",
@@ -695,18 +697,18 @@
"home.report_issue": "报告问题",
"home.samples": "示例",
"home.whats_new": "新功能",
"install.button_tip": "将 SolidCraft 安装为应用:在独立窗口中打开,离线也能使用",
"install.button_tip": "将 OneCAD 安装为应用:在独立窗口中打开,离线也能使用",
"install.dock_1": "选择“文件”›“添加到程序坞”。",
"install.dock_2": "点按“添加”。",
"install.dock_after": "之后 SolidCraft 从程序坞在独立窗口中打开,离线也能使用。",
"install.dock_after": "之后 OneCAD 从程序坞在独立窗口中打开,离线也能使用。",
"install.done": "完成",
"install.home_screen_1": "轻点“共享”(若不在工具栏上,则在 … 菜单中)。",
"install.home_screen_2": "选择“添加到主屏幕”。",
"install.home_screen_3": "轻点“添加”。",
"install.home_screen_after": "之后 SolidCraft 从主屏幕上的图标打开,不显示 Safari 的工具栏,离线也能使用。",
"install.home_screen_after": "之后 OneCAD 从主屏幕上的图标打开,不显示 Safari 的工具栏,离线也能使用。",
"install.menu": "安装应用…",
"install.steps_intro": "Safari 通过自身的菜单安装网页应用:",
"install.title": "安装 SolidCraft",
"install.title": "安装 OneCAD",
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。",
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
@@ -790,10 +792,10 @@
"settings.frame_rate_note": "在视图右下角显示每秒帧数。开启时视图会持续重绘。",
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效",
"settings.msaa_applies_on_restart": "抗锯齿将在下次启动 OneCAD 时生效",
"settings.msaa_reset": "{setting}: {requested}× 不受此图形设备支持;已重置为 {reset}× (支持: {supported})。",
"settings.msaa_unsupported": "此图形设备不支持",
"settings.reset_intro": "这些设置在此计算机上无法正常工作,会导致 SolidCraft 无法启动,因此已更改:",
"settings.reset_intro": "这些设置在此计算机上无法正常工作,会导致 OneCAD 无法启动,因此已更改:",
"settings.reset_title": "设置已更改",
"settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。",
"sketch.degrees_of_freedom": "{count} 个自由度",
@@ -846,8 +848,8 @@
"ui.agent_chat_attach_view": "附加视图",
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
"ui.agent_chat_auth_agent": "使用 {name} 登录",
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 OneCAD。",
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;OneCAD 从不接触你的密码或密钥。",
"ui.agent_chat_auth_none": "该智能体未在此提供登录方式:请按照它自己的文档登录。",
"ui.agent_chat_auth_retry": "我已登录:重新连接",
"ui.agent_chat_auth_terminal": "在终端中运行以下命令,然后再次点击连接:",
@@ -864,12 +866,13 @@
"ui.agent_chat_deny": "拒绝",
"ui.agent_chat_desktop_only": "智能体聊天需要桌面应用。",
"ui.agent_chat_detecting": "正在查找智能体…",
"ui.agent_chat_dock": "停靠到侧面板",
"ui.agent_chat_dock_left": "停靠在左侧",
"ui.agent_chat_dock_right": "停靠在右侧",
"ui.agent_chat_edit": "编辑",
"ui.agent_chat_exited": "智能体已停止。",
"ui.agent_chat_failed_start": "智能体无法启动:{error}",
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 OneCAD 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
"ui.agent_chat_key_in_env": "已设置密钥",
"ui.agent_chat_left": "左侧",
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
@@ -885,12 +888,14 @@
"ui.agent_chat_none_found_short": "未找到智能体",
"ui.agent_chat_not_installed": "未安装",
"ui.agent_chat_not_signed_in": "未登录",
"ui.agent_chat_own_window": "独立窗口",
"ui.agent_chat_permission": "智能体请求运行:{title}",
"ui.agent_chat_pick": "智能体",
"ui.agent_chat_placeholder": "让智能体修改设计…",
"ui.agent_chat_placeholder_busy": "输入消息,待智能体完成后发送(Ctrl+Enter:立即发送)",
"ui.agent_chat_pop_out": "在独立窗口中打开(可移到另一个屏幕)",
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
"ui.agent_chat_privacy_note": "OneCAD 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。OneCAD 从不查看或保存任何密钥或密码。",
"ui.agent_chat_queue": "加入队列",
"ui.agent_chat_queue_full": "等待中的消息太多:请删除一条,或等待智能体完成。",
"ui.agent_chat_queued": "已排队",
@@ -929,7 +934,7 @@
"ui.ai_add_installed": "已安装的智能体",
"ui.ai_add_installed_note": "为已安装的智能体添加一个自己的条目,例如使用其他参数。已安装的智能体会自动列出。",
"ui.ai_added": "已添加",
"ui.ai_agent_mode_tip": "智能体自己的模式:它自己的工具(shell、文件编辑)遵循此模式。SolidCraft 的工具遵循“首选项 > AI”中的权限。",
"ui.ai_agent_mode_tip": "智能体自己的模式:它自己的工具(shell、文件编辑)遵循此模式。OneCAD 的工具遵循“首选项 > AI”中的权限。",
"ui.ai_agents": "智能体",
"ui.ai_always_allow": "始终允许",
"ui.ai_always_allow_tip": "从现在起允许该智能体使用 {tool},不再询问",
@@ -937,7 +942,7 @@
"ui.ai_always_allowed_none": "尚无始终允许的项目。你在聊天中选择“始终允许”的回答会列在这里。",
"ui.ai_auto_allowed": "已自动允许:{title}({why})",
"ui.ai_auto_mode": "你的权限设置",
"ui.ai_auto_ours": "SolidCraft 会自行检查它自己的工具",
"ui.ai_auto_ours": "OneCAD 会自行检查它自己的工具",
"ui.ai_auto_remembered": "你选择了始终允许",
"ui.ai_bad_url": "请输入以 http:// 或 https:// 开头的 URL",
"ui.ai_base_url": "基础 URL",
@@ -951,7 +956,7 @@
"ui.ai_key": "API 密钥",
"ui.ai_key_hint": "可选;保存在系统钥匙串中",
"ui.ai_key_needs_https": "密钥只会通过 HTTPS 发送,或发送到本机。",
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 SolidCraft 的设置里。本地服务器可留空。",
"ui.ai_key_note": "保存在系统钥匙串中,绝不保存在 OneCAD 的设置里。本地服务器可留空。",
"ui.ai_keychain_error": "系统钥匙串拒绝:{error}",
"ui.ai_mode_all": "全部允许",
"ui.ai_mode_all_note": "不再询问。崩溃报告仍不可访问,文件夹限制仍然有效。",
@@ -962,7 +967,7 @@
"ui.ai_mode_edits_note": "建模命令无需询问即可运行;保存、打开、导出等文件操作或破坏性操作仍会询问。",
"ui.ai_mode_global": "按上方设置",
"ui.ai_model": "模型",
"ui.ai_modes_scope": "这些设置适用于 SolidCraft 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。",
"ui.ai_modes_scope": "这些设置适用于 OneCAD 的工具。智能体自己的工具(它的 shell、文件编辑)遵循智能体自己的模式;如果智能体提供模式,可在聊天面板标题栏中选择。",
"ui.ai_name": "名称",
"ui.ai_need_command": "请输入启动智能体的命令",
"ui.ai_need_model": "请输入模型名称",
@@ -1367,6 +1372,8 @@
"ui.reset_the_settings_on_this_page": "重置此页面上的设置",
"ui.restore": "还原",
"ui.restore_defaults": "恢复默认值",
"ui.reverse_horizontal_pan_direction": "反转水平平移方向",
"ui.reverse_horizontal_pan_tip": "水平双指滚动和拇指滚轮向相反方向平移",
"ui.reverse_zoom_direction": "反转缩放方向",
"ui.revolve": "旋转",
"ui.right": "右视",
@@ -1490,31 +1497,31 @@
"ui.zoom": "缩放",
"ui.zoom_to_cursor_tip": "滚轮向光标所在点缩放(关闭:向视图中心缩放)",
"ui.zoom_to_the_cursor": "缩放到光标",
"update.available": "SolidCraft {version} 已发布",
"update.available": "OneCAD {version} 已发布",
"update.button": "更新",
"update.button_tip": "SolidCraft {version} 已发布",
"update.button_tip": "OneCAD {version} 已发布",
"update.button_version": "更新到 {version}",
"update.check_automatically": "自动检查更新",
"update.check_now": "立即检查",
"update.checking": "正在检查…",
"update.copy": "复制",
"update.dmg_opened": "磁盘映像已校验并打开:将 SolidCraft 拖到“应用程序”替换旧版本,然后重启 SolidCraft。",
"update.dmg_opened": "磁盘映像已校验并打开:将 OneCAD 拖到“应用程序”替换旧版本,然后重启 OneCAD。",
"update.download": "下载",
"update.download_open": "下载并打开",
"update.downloading": "正在下载并校验… {done} / {total} MB",
"update.failed": "无法检查: {error}",
"update.file": "将下载 {file}。",
"update.install_failed": "更新未安装:{error}。未做任何更改;可用“下载”获取安装包。",
"update.install_msi": "安装(将关闭 SolidCraft)",
"update.installed": "SolidCraft {version} 已安装。重新启动后生效。",
"update.installer_started": "SolidCraft 关闭后将启动安装程序。",
"update.install_msi": "安装(将关闭 OneCAD)",
"update.installed": "OneCAD {version} 已安装。重新启动后生效。",
"update.installer_started": "OneCAD 关闭后将启动安装程序。",
"update.later": "稍后提醒",
"update.no_notes": "此版本没有发布说明。",
"update.package_hint": "此副本来自 {kind}。下载新软件包后用以下命令安装:",
"update.prefs_dev": "这是开发版本({version}),不检查更新。",
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。SolidCraft 不会自行检查。",
"update.prefs_flatpak": "此副本({version})由 Flatpak 更新:运行 {command} 或使用软件中心。OneCAD 不会自行检查。",
"update.prefs_label": "更新",
"update.prefs_note": "当前为 SolidCraft {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
"update.prefs_note": "当前为 OneCAD {version}({kind})。检查时只向 GitHub 查询最新版本,不发送其他任何数据。",
"update.release_page": "打开发布页面",
"update.restart": "立即重启",
"update.restart_later": "稍后",
@@ -1522,7 +1529,7 @@
"update.skip": "跳过此版本",
"update.system_hint": "此副本为 {kind}。用以下命令更新:",
"update.title": "有可用更新",
"update.up_to_date": "SolidCraft 已是最新版本。",
"update.up_to_date": "OneCAD 已是最新版本。",
"update.update_now": "立即更新",
"update.you_have": "当前版本为 {version}。"
}
+3 -1
View File
@@ -3,5 +3,7 @@
#solidcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #4a5060; font: 14px system-ui, sans-serif; background: #e8ebf0; z-index: 10; transition: opacity 300ms ease; }
#solidcraft_loading.done { opacity: 0; pointer-events: none; }
#solidcraft_loading img { width: min(60vmin, 360px); height: auto; }
/* OneBSpace (OneCAD): the upstream logo image is gone — a plain wordmark instead. */
#solidcraft_loading .wordmark { font: 600 clamp(28px, 6vmin, 52px) system-ui, sans-serif;
letter-spacing: 0.22em; text-indent: 0.22em; color: #4a5060; user-select: none; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+14 -3
View File
@@ -253,9 +253,20 @@ impl Document {
return self.certified_pieces(body, indices, target.origin_feature);
}
if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate {
let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| {
matches!(source, Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index)
}).collect();
let side = |index: usize, exact: bool| matches!(body.body.face_sources().and_then(|sources| sources.get(index)), Some(Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. })) if *producer == target.origin_feature && current_sketch == sketch && (!exact || current_curve == curve));
let count = body.body.face_sources().map_or(0, |sources| sources.len());
let mut indices: Vec<_> = (0..count).filter(|index| side(*index, true)).collect();
// A side drawn in collinear pieces is one face over all of them (#1788): a certificate
// made when the pieces were faces of their own finds it by the piece's name, which
// the joined face answers to.
if indices.is_empty() {
let names = crate::naming::names(body);
indices = (0..count)
.filter(|index| {
side(*index, false) && crate::history_names::matches(&names, *index, &target.name) == Some(crate::history_names::Match::Alias)
})
.collect();
}
if indices.is_empty() {
return Err(DocError::Unknown("constructed side certificate no longer exists".into()));
}
+123
View File
@@ -0,0 +1,123 @@
//! The existing length display policy, shared by windowed and headless consumers.
//!
//! Values are stored in millimetres and displayed in the five design units. Unknown unit
//! names retain the millimetre fallback; expression units have a separate, broader vocabulary.
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
/// Units per millimetre and the unit's name. Unknown names use millimetres.
pub fn unit_scale(units: &str) -> (f64, &'static str) {
match units {
"cm" => (0.1, "cm"),
"m" => (0.001, "m"),
"in" => (1.0 / 25.4, "in"),
"ft" => (1.0 / 304.8, "ft"),
_ => (1.0, "mm"),
}
}
/// Inch labels retain a meaningful fourth digit without promoting floating-point noise.
/// `value` is already converted to the requested display unit, not a millimetre value.
pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize {
let base = usize::from(decimals.min(8));
if units != "in" {
return base;
}
let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5;
base.max(if needs_four { 4 } else { 3 })
}
/// A millimetre length in the requested display unit, without a unit suffix.
pub fn length_number(mm: f64, units: &str, decimals: u8) -> String {
let value = mm * unit_scale(units).0;
format!("{:.*}", length_precision(value, units, decimals), value)
}
/// A length in millimetres in `units`, with `decimals` (for display).
pub fn format_length(mm: f64, units: &str, decimals: u8) -> String {
let (_, u) = unit_scale(units);
format!("{} {u}", length_number(mm, units, decimals))
}
#[cfg(test)]
mod tests {
use super::{format_length, length_number, length_precision, unit_scale};
#[test]
fn all_design_units_keep_bare_and_suffixed_labels() {
for (mm, units, bare, suffixed) in [
(12.5, "mm", "12.50", "12.50 mm"),
(125.0, "cm", "12.50", "12.50 cm"),
(12_500.0, "m", "12.50", "12.50 m"),
(317.5, "in", "12.500", "12.500 in"),
(3810.0, "ft", "12.50", "12.50 ft"),
] {
assert_eq!(length_number(mm, units, 2), bare, "{units}");
assert_eq!(format_length(mm, units, 2), suffixed, "{units}");
}
}
#[test]
fn inch_labels_keep_three_or_four_digits_without_promoting_noise() {
for (mm, decimals, expected) in [
(25.4, 0, "1.000"),
(25.4 * 1.234, 2, "1.234"),
(25.4 * 1.2345, 0, "1.2345"),
(-25.4 * 1.2345, 3, "-1.2345"),
(25.4 * 0.001, 2, "0.001"),
(25.4 * (1.0 + 1e-12), 2, "1.000"),
(25.4, 4, "1.0000"),
(25.4, 5, "1.00000"),
(0.0, 0, "0.000"),
] {
assert_eq!(length_number(mm, "in", decimals), expected, "{mm} mm, {decimals} places");
}
}
#[test]
fn precision_uses_the_converted_value_and_caps_requested_places() {
assert_eq!(length_precision(1.2345, "in", 2), 4);
assert_eq!(length_precision(1.234, "in", 2), 3);
assert_eq!(length_precision(1.2345, "mm", 2), 2);
assert_eq!(length_precision(1.2345, "in", 6), 6);
assert_eq!(length_precision(1.2345, "in", u8::MAX), 8);
assert_eq!(length_precision(1.2345, "mm", u8::MAX), 8);
assert_eq!(length_number(12.5, "mm", u8::MAX), "12.50000000");
assert_eq!(length_number(25.4, "in", u8::MAX), "1.00000000");
}
#[test]
fn rounding_and_signed_zero_keep_the_existing_display_policy() {
for (mm, decimals, expected) in [
(0.0, 2, "0.00"),
(-0.0, 2, "-0.00"),
(-0.0001, 2, "-0.00"),
(-12.5, 2, "-12.50"),
(12.5, 0, "12"),
(13.5, 0, "14"),
(-12.5, 0, "-12"),
(-13.5, 0, "-14"),
(1.25, 1, "1.2"),
(1.75, 1, "1.8"),
] {
assert_eq!(length_number(mm, "mm", decimals), expected, "{mm} mm, {decimals} places");
}
assert_eq!(format_length(-0.0, "in", 2), "-0.000 in");
}
#[test]
fn unknown_display_units_keep_the_millimetre_fallback() {
for units in ["", "inch", " in ", "IN", "unsupported"] {
assert_eq!(unit_scale(units), (1.0, "mm"), "{units}");
assert_eq!(length_number(25.4, units, 2), "25.40", "{units}");
assert_eq!(format_length(25.4, units, 2), "25.40 mm", "{units}");
}
}
#[test]
fn non_finite_text_is_preserved_without_claiming_validation() {
assert_eq!(length_number(f64::NAN, "mm", 2), "NaN");
assert_eq!(format_length(f64::INFINITY, "in", u8::MAX), "inf in");
assert_eq!(format_length(f64::NEG_INFINITY, "cm", 2), "-inf cm");
}
}
+40 -3
View File
@@ -1465,6 +1465,19 @@ pub enum FeatureKind {
#[serde(default = "boundary_samples")]
samples: usize,
},
/// One surface through a curve network (`surface.boundary` with `u` and `v`, issue #763): the
/// curves `u` run one way (two or more: the surface lofts through them) and `v` cross them the
/// other way (two each way: a Coons patch; more: a Gordon surface through every curve). A side
/// may be a point (a three-sided patch). A curve that is a chain of a surface's open edges may
/// carry G1 or G2 with the face beside it. `operation` join sews the surface into the body of
/// its first edge curve (a solid when it closes the gap there).
SurfaceNetwork {
u: Vec<NetworkCurve>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
v: Vec<NetworkCurve>,
#[serde(default)]
operation: Operation,
},
/// One face of a surface body rebuilt against another body (`surface.match`): the rebuilt
/// boundary nearest the chain of `edges` on `target` takes that chain's place and repeats the
/// target's own shape along the seam up to `order`, while the boundary opposite it, and the
@@ -1692,6 +1705,27 @@ pub struct BlendSide {
pub edges: Vec<Position>,
}
/// One curve of a boundary surface's network ([`FeatureKind::SurfaceNetwork`]): a chain of a
/// sketch's `curves` (planar or 3D, meeting end to end), a chain of `body`'s edges picked by points
/// on them (`edges`), or a side collapsed to the point `point` (the apex of a three-sided patch).
/// A chain of a surface's open edges may carry `continuity` with the face beside it (G0, the
/// default, G1 or G2).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct NetworkCurve {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sketch: Option<SketchId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub curves: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<BodyName>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<Position>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point: Option<Position>,
#[serde(default = "patch_g0", skip_serializing_if = "is_g0")]
pub continuity: Continuity,
}
/// How a patch on a loop of edges meets the faces beside the loop: every edge with `all`, but the
/// edges picked in `edges` with their own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
@@ -1733,9 +1767,12 @@ fn is_g0(g: &Continuity) -> bool {
/// The continuity a blend repeats by default: the tangent plane on both sides.
/// The stations a boundary patch carries by default.
fn boundary_samples() -> usize {
25
BOUNDARY_SAMPLES
}
/// The stations a boundary patch carries when a design does not say.
pub const BOUNDARY_SAMPLES: usize = 25;
fn blend_orders() -> [Continuity; 2] {
[Continuity::G1, Continuity::G1]
}
@@ -2088,7 +2125,7 @@ impl FeatureKind {
FeatureKind::SurfaceSweep { .. } => "SurfaceSweepFeature",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoftFeature",
FeatureKind::SurfaceBlend { .. } => "BlendFeature",
FeatureKind::SurfaceBoundary { .. } => "BoundaryFeature",
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "BoundaryFeature",
FeatureKind::SurfaceMatch { .. } => "MatchFeature",
FeatureKind::SurfaceFair { .. } => "FairFeature",
FeatureKind::Stitch { .. } => "StitchFeature",
@@ -2168,7 +2205,7 @@ impl FeatureKind {
FeatureKind::SurfaceSweep { .. } => "SurfaceSweep",
FeatureKind::SurfaceLoft { .. } => "SurfaceLoft",
FeatureKind::SurfaceBlend { .. } => "Blend",
FeatureKind::SurfaceBoundary { .. } => "Boundary",
FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. } => "Boundary",
FeatureKind::SurfaceMatch { .. } => "Match",
FeatureKind::SurfaceFair { .. } => "Fair",
FeatureKind::Stitch { .. } => "Stitch",
+44 -15
View File
@@ -1035,6 +1035,20 @@ fn placement_digest(doc: &Document, f: &Feature) -> u64 {
}
return hash.finish();
}
// A network through 3D sketch curves reads their points through the components the
// feature and those sketches are placed in, as a sweep's wired path does.
if let FeatureKind::SurfaceNetwork { u, v, .. } = &f.kind {
let sketches: Vec<crate::SketchId> = u.iter().chain(v).filter_map(|c| c.sketch).collect();
if sketches.iter().any(wired) {
let mut hash = std::collections::hash_map::DefaultHasher::new();
doc.component_transform(f.component).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
for sketch in &sketches {
let owner = doc.feature(sketch.0).map(|feature| feature.component).unwrap_or(0);
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
}
return hash.finish();
}
}
// To / From Object finds its target where that target's component is placed (#928).
let to_object = match &f.kind {
FeatureKind::Extrude { extent, .. } => extent.to.is_some() || extent.from.is_some(),
@@ -1750,8 +1764,9 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
Operation::Cut | Operation::Intersect => {
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
let tools = if op == Operation::Cut { clear_tools_as_one(tools) } else { tools };
let affected: std::collections::BTreeSet<String> =
target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
// Each target's pieces before the cut: a body joined from separate pieces stays one.
let affected: std::collections::BTreeMap<String, usize> =
target_idx.iter().filter_map(|i| state.bodies.get(*i)).map(|b| (b.name.clone(), b.body.lumps().map_or(1, |l| l.len()))).collect();
let mut remove = Vec::new();
for &ti in &target_idx {
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.traced())) else { continue };
@@ -1779,16 +1794,19 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
state.threads.retain(|t| live.contains(t.body.as_str()));
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"),
// numbered on past names already taken (a body cut in pieces before).
// numbered on past names already taken (a body cut in pieces before). Only a cut that
// made more pieces than the body had splits it: the pieces of a join stay together.
// Not yet: a cut that splits one piece of such a body still splits all of them; keeping
// the untouched ones together needs each new piece traced to the piece it came from.
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
let mut split: Vec<ModelBody> = Vec::new();
for mb in std::mem::take(&mut state.bodies) {
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
let Some(&had) = affected.get(&mb.name).filter(|_| !state.sheets.iter().any(|s| s.body == mb.name)) else {
split.push(mb);
continue;
}
};
let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]);
if pieces.len() < 2 {
if pieces.len() <= had {
split.push(mb);
continue;
}
@@ -3065,7 +3083,8 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
};
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
// wire can tilt an otherwise exact planar cap through numeric cancellation.
let bodies = kernel::extrude(&Plane::XY, std::slice::from_ref(region), start, end)?;
// A side drawn in collinear pieces is one face (#1788).
let bodies = kernel::extrude_whole_sides(&Plane::XY, std::slice::from_ref(region), start, end)?;
if *plane == Plane::XY {
return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect());
}
@@ -3176,21 +3195,30 @@ fn feature_tools_with_constructor(
};
if let Some(tp) = &extent.taper {
let taper = val(vals, tp, Kind::Angle)?;
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into()));
if extent.distance2.is_some() || extent.direction == crate::Direction::Symmetric {
return Err(DocError::Invalid("not supported yet: taper with two-sided or symmetric extents".into()));
}
let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
let start_plane = ss.plane.offset(from);
// Where the extrude starts: the From Object's level and a start offset along the
// normal from it. The offset moves the profile that far and the sides are drafted
// from there, so the profile stays the section at the start whatever the offset
// (`d` is measured from the start already, To Object included).
let base = from
+ match &extent.start_offset {
Some(e) => val(vals, e, Kind::Length)?,
None => 0.0,
};
let start_plane = ss.plane.offset(base);
let caps = [
Some(crate::lineage::extrude_cap_role(from)),
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })),
Some(crate::lineage::extrude_cap_role(base)),
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { base - len } else { base + len })),
];
// 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 plain_targets: Vec<String> = crate::plain(targets);
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, &plain_targets);
let on_face = matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, base, &plain_targets);
let surroundings = Surroundings::of(st, *operation, &plain_targets);
let tools: Vec<Traced> = regions
.iter()
@@ -3214,9 +3242,9 @@ fn feature_tools_with_constructor(
growth = growth.max(len * taper.tan().abs());
return Ok(roles.tool(exact, None));
}
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
let (lo, hi) = if sign < 0.0 { (base - len, base) } else { (base, base + len) };
let (l2, h2) = surroundings.as_ref().map_or((lo, hi), |sur| extend_for_coplanar(sur, &ss.plane, r, lo, hi));
let (mut a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
let (mut a, b) = if sign < 0.0 { (base - h2, base - l2) } else { (l2 - base, h2 - base) };
// A taper that narrows the tool away from the face widens it as it runs on into it, past
// the profile (where the profile is the whole face, out through the body's sides, #1534):
// then it starts on the face instead.
@@ -4691,6 +4719,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SurfaceLoft { .. }
| FeatureKind::SurfaceBlend { .. }
| FeatureKind::SurfaceBoundary { .. }
| FeatureKind::SurfaceNetwork { .. }
| FeatureKind::SurfaceMatch { .. }
| FeatureKind::SurfaceFair { .. }
| FeatureKind::SurfaceOffset { .. }
+168 -2
View File
@@ -151,12 +151,49 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
f.component,
)?;
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
// Two curves are the two sides of a bridge. Three or four run round a loop: the Coons
// patch of the loop, which lies in the sketch's plane with them, so every order holds.
let body = if curves.len() >= 3 {
if *crown != 0.0 {
return Err(DocError::Invalid(
"not supported yet: crown on a patch through three or four curves (it is the flat patch of its sides)".into(),
));
}
loop_patch(&ss, curves)?
} else {
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
kernel::boundary_patch(&boundaries, *crown, *samples)?
};
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, body, f.id));
Ok(())
}
FeatureKind::SurfaceNetwork { u, v, operation } => {
if !matches!(operation, crate::Operation::NewBody | crate::Operation::Join) {
return Err(DocError::Invalid("a boundary surface is a new body or joins the body of its edges".into()));
}
let us = network_sources(doc, st, f, u, "U")?;
let vs = network_sources(doc, st, f, v, "V")?;
let made = kernel::boundary_surface(&network_curves(&us), &network_curves(&vs))?;
if *operation == crate::Operation::Join {
// Into the body of the first curve picked on a body's edges.
let target = u
.iter()
.chain(v)
.find_map(|c| c.body.clone())
.ok_or_else(|| DocError::Invalid("join needs a curve on a body's edges: the surface is sewn into that body".into()))?;
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let joined = kernel::boundary_join(&mb.body, &made)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, joined, mb.feature);
}
return Ok(());
}
let name = new_name(st, f, 0);
st.bodies.push(ModelBody::new(name, made, f.id));
Ok(())
}
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
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())) };
@@ -439,6 +476,135 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
}
/// The most curves one direction of a network may list (the kernel's own cap).
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
/// A loop of three or four sketch curves as one patch: four give the Coons patch of opposite pairs
/// (the first and third one way, the second and fourth the other); three collapse the side
/// opposite the first curve to the corner where the other two meet.
fn loop_patch(ss: &SolvedSketch, curves: &[String]) -> Result<kernel::Body> {
let mut chains: Vec<Vec<Seg2>> = Vec::with_capacity(curves.len());
for id in curves {
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
if segs.is_empty() {
return Err(DocError::Invalid(format!("curve `{id}` has no geometry to build on")));
}
chains.push(segs);
}
let side = |k: usize| -> Result<kernel::BoundaryCurve<'static>> {
let (Some(id), Some(segs)) = (curves.get(k), chains.get(k)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
Ok(kernel::BoundaryCurve {
label: format!("curve `{id}`"),
source: kernel::CurveSource::Sketch { plane: ss.plane, segs: segs.clone() },
continuity: kernel::Continuity::G0,
})
};
match curves.len() {
4 => Ok(kernel::boundary_surface(&[side(0)?, side(2)?], &[side(1)?, side(3)?])?),
3 => {
// The corner the second and third curves share, whichever way each was drawn.
let ends = |k: usize| -> Option<[Vec2; 2]> {
let c = chains.get(k)?;
Some([c.first()?.start(), c.last()?.end()])
};
let (Some(b), Some(c)) = (ends(1), ends(2)) else { return Err(DocError::Invalid("a boundary curve is missing".into())) };
let mut best = (f64::INFINITY, Vec2::new(0.0, 0.0));
for p in b {
for q in c {
if p.dist(q) < best.0 {
best = (p.dist(q), (p + q) * 0.5);
}
}
}
let apex = kernel::BoundaryCurve {
label: "the corner opposite the first curve".into(),
source: kernel::CurveSource::Point(ss.plane.to_world(best.1)),
continuity: kernel::Continuity::G0,
};
Ok(kernel::boundary_surface(&[side(0)?, apex], &[side(1)?, side(2)?])?)
}
_ => Err(DocError::Invalid("a loop patch takes three or four curves".into())),
}
}
/// A network curve resolved in the feature's frame, owning what it is built from.
enum NetSource {
Sketch(solidcraft_geom::Plane, Vec<Seg2>),
Points(Vec<Vec3>),
Edges(kernel::Body, Vec<Vec3>),
Point(Vec3),
}
/// The curves of one direction of a network, resolved: each one sketch chain, 3D sketch chain,
/// edge chain or point, labelled `U1`, `U2`, … (with `dir` "U") for messages.
fn network_sources(
doc: &Document,
st: &ModelState,
f: &Feature,
list: &[crate::NetworkCurve],
dir: &str,
) -> Result<Vec<(String, NetSource, kernel::Continuity)>> {
if list.len() > MAX_NETWORK_CURVES {
return Err(DocError::Invalid(format!("at most {MAX_NETWORK_CURVES} curves each way")));
}
let mut out = Vec::with_capacity(list.len());
for (k, c) in list.iter().enumerate() {
let label = format!("{dir}{}", k + 1);
let given = [c.sketch.is_some() || !c.curves.is_empty(), c.body.is_some() || !c.edges.is_empty(), c.point.is_some()];
if given.iter().filter(|g| **g).count() != 1 {
return Err(DocError::Invalid(format!("{label} must be sketch curves, a body's edges or a point")));
}
let source = if let Some(p) = c.point {
NetSource::Point(p.0)
} else if let Some(name) = &c.body {
if c.edges.is_empty() {
return Err(DocError::Invalid(format!("{label} needs points on the edges of {name}")));
}
let b = st.body(name).ok_or_else(|| DocError::Unknown(format!("body `{name}`")))?;
NetSource::Edges(b.body.clone(), crate::plain(&c.edges))
} else {
let sk = c.sketch.ok_or_else(|| DocError::Invalid(format!("{label} needs the sketch its curves are in")))?;
if c.curves.is_empty() || c.curves.len() > MAX_CHAIN_CURVES {
return Err(DocError::Invalid(format!("{label} needs 1 to {MAX_CHAIN_CURVES} curves of its sketch")));
}
let raw = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
// 3D sketch curves are followed through their points; planar ones exactly.
let mapped = path_in_frame(doc, raw, f.component)?;
match path_points(&mapped, &c.curves, Vec3::new(f64::NAN, f64::NAN, f64::NAN))? {
Some(pts) => NetSource::Points(pts),
None => {
let ss = sketch_in_feature_frame(doc, raw, f.component)?;
let mut chains = curve_chains(&ss, &c.curves)?.into_iter();
let (Some(chain), None) = (chains.next(), chains.next()) else {
return Err(DocError::Invalid(format!("{label}: its curves must meet end to end in one chain")));
};
NetSource::Sketch(ss.plane, chain)
}
}
};
out.push((label, source, c.continuity));
}
Ok(out)
}
/// The kernel's curves over resolved sources (borrowing their bodies).
fn network_curves(sources: &[(String, NetSource, kernel::Continuity)]) -> Vec<kernel::BoundaryCurve<'_>> {
sources
.iter()
.map(|(label, src, g)| kernel::BoundaryCurve {
label: label.clone(),
source: match src {
NetSource::Sketch(plane, segs) => kernel::CurveSource::Sketch { plane: *plane, segs: segs.clone() },
NetSource::Points(pts) => kernel::CurveSource::Points(pts.clone()),
NetSource::Edges(body, at) => kernel::CurveSource::Edges { body, at: at.clone() },
NetSource::Point(p) => kernel::CurveSource::Point(*p),
},
continuity: *g,
})
.collect()
}
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
@@ -164,6 +164,10 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
// The Class-A surface family (surface.blend/boundary/match/fair).
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
(
"surface_network",
json!({"type": "surface_network", "u": [{"sketch": 1, "curves": ["l1"]}, {"body": "B", "edges": [o], "continuity": "g1"}], "v": [{"sketch": 2, "curves": ["l1"]}, {"point": o}], "operation": "join"}),
),
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
("unstitch", json!({"type": "unstitch", "body": "B"})),
@@ -342,6 +342,7 @@ surface_offset
expressions: soff.distance, soff.tolerance
surface_blend
surface_boundary
surface_network
surface_match
surface_fair
inputs:
@@ -157,6 +157,8 @@ surface_offset
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_boundary
surface_network
moves: u[1].edges[0][0], u[1].edges[0][1], u[1].edges[0][2], v[1].point[0], v[1].point[1], v[1].point[2]
surface_match
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
face points: [0, 0, 0]
@@ -182,6 +182,10 @@ surface_blend
surface_boundary
sketches: [1]
remap_refs: sketch
surface_network
sketches: [1, 2]
remap_refs: u[0].sketch, v[0].sketch
remap_bodies: u[1].body
surface_match
remap_bodies: body
surface_fair
@@ -0,0 +1,7 @@
//! A `SurfaceNetwork` (Boundary Surface through a curve network, #763) saves `u` and `v`, each a
//! list of curves: a `sketch` and its `curves`, a `body` and points on its `edges`, or a `point`
//! (the apex of a three-sided patch), with an optional `continuity` (`g1` or `g2` with the face
//! beside an edge chain), and an `operation` (`new_body`, or `join` into the body of its edges).
//! A `SurfaceBoundary` of three or four curves, which older builds refused, now builds and saves
//! as it always did. Older designs need no transformation; the last release asks for an update
//! instead of meeting a feature it can't read.
+1
View File
@@ -17,6 +17,7 @@ pub use clipboard::{clone_expression, remap_topology};
pub mod config;
pub mod construct;
pub mod display_quality;
pub mod display_units;
mod document;
mod eval;
pub mod expr;
@@ -808,3 +808,43 @@ fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
}
}
}
/// Two posts joined into one body while apart, trimmed by a cut that splits neither, then joined
/// to a case they stand on. The cut split the joined posts into "Posts" and "Posts (1)" without
/// saying so (`created_bodies` was empty), and the join then took only "Posts", leaving the other
/// post behind as a body of its own. A cut that does cut a body in two still makes two bodies,
/// and its reply names the new one.
#[test]
fn cut_keeps_posts_joined_while_apart_in_one_body() {
let mut s = Session::default();
let post = |name: &str, x: f64| json!({"length": 10, "width": 10, "height": 30, "center": [x, 0, 25], "operation": "new", "body_name": name});
s.execute("solid.box", &json!({"length": 60, "width": 40, "height": 10, "center": [0, 0, 5], "operation": "new", "body_name": "Case"})).unwrap();
s.execute("solid.box", &post("Posts", -20.0)).unwrap();
s.execute("solid.box", &post("Post2", 20.0)).unwrap();
s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Post2"], "operation": "join"})).unwrap();
s.execute("solid.box", &json!({"length": 80, "width": 20, "height": 10, "center": [0, 0, 40], "operation": "new", "body_name": "Trim"})).unwrap();
let cut = s.execute("solid.combine", &json!({"target": "Posts", "tools": ["Trim"], "operation": "cut"})).unwrap();
// Each post loses its top 5 mm: 10 x 10 x 25 left of each.
let posts = 2.0 * 10.0 * 10.0 * 25.0;
assert_eq!(cut["bodies"], json!(["Case", "Posts"]), "{cut}");
assert_eq!(cut["created_bodies"], json!([]), "{cut}");
assert_eq!(s.model.state().body("Posts").map(|b| b.body.shell_count()), Some(2));
assert!((volume(&s, "Posts") - posts).abs() < 1e-6 * posts, "{}", volume(&s, "Posts"));
let join = s.execute("solid.combine", &json!({"target": "Case", "tools": ["Posts"], "operation": "join"})).unwrap();
assert_eq!(join["bodies"], json!(["Case"]), "{join}");
assert_eq!(join["removed_bodies"], json!(["Posts"]), "{join}");
let want = 60.0 * 40.0 * 10.0 + posts;
let got = volume(&s, "Case");
println!("posts joined while apart, cut, joined: Case {got:.6} mm³, expected {want:.6}");
assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}");
// A cut through the middle of the case still leaves two bodies, and says it made the second.
s.execute("solid.box", &json!({"length": 2, "width": 80, "height": 80, "center": [0, 0, 20], "operation": "new", "body_name": "Saw"})).unwrap();
let saw = s.execute("solid.combine", &json!({"target": "Case", "tools": ["Saw"], "operation": "cut"})).unwrap();
assert_eq!(saw["bodies"], json!(["Case", "Case (1)"]), "{saw}");
assert_eq!(saw["created_bodies"], json!(["Case (1)"]), "{saw}");
let half = (60.0 - 2.0) / 2.0 * 40.0 * 10.0 + posts / 2.0;
for name in ["Case", "Case (1)"] {
assert!((volume(&s, name) - half).abs() < 1e-6 * half, "{name}: {} vs {half}", volume(&s, name));
}
}
+26 -1
View File
@@ -87,7 +87,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
let component = component_of(s, p, cmd)?;
let items = keys(p, "items", cmd)?;
let d = s.doc_mut();
let id = d.browser_groups.iter().map(|g| g.id).max().unwrap_or(0) + 1;
let id = super::next_free_id(cmd, d.browser_groups.iter().map(|g| g.id))?;
let name = match str_(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) if n.len() <= 128 => n.to_string(),
Some(_) => return Err(bad(cmd, "`name` is too long")),
@@ -1004,6 +1004,31 @@ mod tests {
assert_eq!(s.visibility, before);
}
/// A design file can carry any id, the largest a `u64` holds too: a new group then has no id
/// to take. That is an error, not an overflow (a panic where overflow is checked, a duplicate
/// id where it is not).
#[test]
fn a_new_group_after_the_largest_id_is_an_error_not_an_overflow() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
let a = names(&s)[0].clone();
run(&mut s, "browser.group", json!({"folder": "bodies", "items": [a.clone()]}));
s.doc_mut().browser_groups[0].id = u64::MAX;
let result = s.execute("browser.group", &json!({"folder": "bodies", "items": [a]}));
assert!(!matches!(result, Err(crate::EngineError::Internal(..))), "panicked inside the command: {result:?}");
assert!(result.is_err(), "no id is left to give: {result:?}");
assert_eq!(s.doc.browser_groups.len(), 1, "nothing was added");
}
#[test]
fn the_next_free_id_is_one_past_the_largest_and_never_wraps() {
use super::super::next_free_id;
assert_eq!(next_free_id("t", std::iter::empty()).unwrap(), 1);
assert_eq!(next_free_id("t", [3, 9, 4].into_iter()).unwrap(), 10);
assert_eq!(next_free_id("t", [u64::MAX - 1].into_iter()).unwrap(), u64::MAX);
assert!(next_free_id("t", [1, u64::MAX].into_iter()).is_err());
}
#[test]
fn groups_hold_items_and_follow_renames() {
let mut s = Session::default();
+5 -1
View File
@@ -978,6 +978,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
.chain(super::features_more::COMMANDS)
.chain(super::construct::COMMANDS)
.chain(super::surface::COMMANDS)
.chain(super::surface_boundary::COMMANDS)
.any(|candidate| candidate.id == spec.id)
|| matches!(
spec.id,
@@ -1049,7 +1050,10 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
{
*after = before.clone();
}
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) {
// A boundary surface's loop of sketch curves and its curve network are one command's two
// forms: an edit may turn one into the other.
let boundary = |k: &FeatureKind| matches!(k, FeatureKind::SurfaceBoundary { .. } | FeatureKind::SurfaceNetwork { .. });
if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) && !(boundary(&new.kind) && boundary(&old.kind)) {
return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid)));
}
// Optional inputs can move other inputs' positions (e.g. Distance 2 goes before Taper).
+3 -2
View File
@@ -547,10 +547,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
// The bodies this feature made, apart from the ones it changed or that were there (#374).
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
// Bodies that were there before and now have other geometry, and bodies that are gone.
let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect();
// The bodies this feature made, apart from the ones it changed or that were there (#374),
// and the pieces a cut left of a body made earlier, which keep that body's feature.
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id || !old.contains_key(b.name.as_str())).map(|b| b.name.as_str()).collect();
let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
let changed: Vec<&str> =
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
@@ -952,6 +952,165 @@ fn a_tapered_foot_under_a_part_joins_as_drawn() {
}
}
/// A drafted extrude that starts above its sketch plane. The start offset moves the profile
/// along the sketch normal and the walls are drafted from there, so the section at the start
/// plane is the profile itself and the solid is the frustum over it, moved along the normal by
/// the offset. Controls that build today: the same extrude with the taper and no offset, and
/// with the offset and no taper. Two-sided and symmetric tapers are still refused, by name.
#[test]
fn a_drafted_extrude_starts_at_an_offset() {
use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body};
// A 30 x 20 profile, 12 mm long, drafted 6 deg, started 4 mm along the normal.
let (w, d, h, off) = (30.0, 20.0, 12.0, 4.0);
let part = |p: Value| -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", p);
s
};
// The straight prism, and the frustum over the profile: every edge moves out by z*tan a
// over the start, so A(z) = (w + 2 z t)(d + 2 z t) and
// V = integral of A over 0..h = w*d*h + (w + d)*t*h^2 + 4/3*t^2*h^3 (exact: A is quadratic).
let prism = w * d * h;
let frustum = |t: f64| w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h;
// The two caps plus four trapezoid walls, each (base + top)/2 by the slant height h/cos a.
let area_of = |a: f64| {
let t = a.tan();
w * d + (w + 2.0 * h * t) * (d + 2.0 * h * t) + 2.0 * h / a.cos() * ((w + h * t) + (d + h * t))
};
// Control: the taper alone, from the sketch plane.
let want = frustum(6f64.to_radians().tan());
let mut s = part(json!({"distance":h,"taper":"6 deg","body_name":"B"}));
let v = volume(&mut s);
assert!(rel(v, want) < 1e-9, "taper alone: {v} mm3 for {want}");
// Control: the offset alone, no taper: the prism, moved along the normal.
let mut s = part(json!({"distance":h,"start_offset":off,"body_name":"B"}));
let v = volume(&mut s);
assert!(rel(v, prism) < 1e-9, "offset alone: {v} mm3 for {prism}");
let st = s.model.state();
let m = measure_body(&st.body("B").unwrap().body).unwrap();
assert!((m.bbox.min.z - off).abs() < 1e-9 && (m.bbox.max.z - (off + h)).abs() < 1e-9, "offset alone: {:?}", m.bbox);
// Both: the frustum over the profile, standing on the offset plane, either way along the
// normal and drafted either outward or inward.
for deg in [6.0f64, -6.0] {
for (dir, sign) in [("positive", 1.0f64), ("negative", -1.0)] {
let (ang, t) = (deg.to_radians(), deg.to_radians().tan());
let (v_want, a_want) = (frustum(t), area_of(ang));
let at = format!("{deg} deg {dir}");
let mut s = part(json!({"distance":h,"direction":dir,"start_offset":off,"taper":format!("{deg} deg"),"body_name":"B"}));
let v = volume(&mut s);
assert!(rel(v, v_want) < 1e-9, "{at}: {v} mm3 for {v_want}");
let st = s.model.state();
let b = &st.body("B").unwrap().body;
let m = measure_body(b).unwrap();
assert_eq!(st.bodies.len(), 1, "{at}: one body");
assert_eq!(m.shells, 1, "{at}: one lump");
assert_eq!(m.merged.faces, 6, "{at}: four walls and two caps");
// The area pins both caps: the profile at the offset plane, the drafted section at
// the far end. (The volume alone cannot: it is measured on a tessellation.)
assert!(rel(m.area, a_want) < 1e-9, "{at}: {} mm2 for {a_want}", m.area);
// The solid lies between the offset plane and the far end, and the widening (or
// narrowing) is all on the far side of the profile.
let (lo, far) = if sign > 0.0 { (off, off + h) } else { (off - h, off) };
assert!((m.bbox.min.z - lo).abs() < 1e-9 && (m.bbox.max.z - (lo + h)).abs() < 1e-9, "{at}: {:?}", m.bbox);
let (x0, x1) = (0f64.min(-h * t), w.max(w + h * t));
let (y0, y1) = (0f64.min(-h * t), d.max(d + h * t));
assert!((m.bbox.min.x - x0).abs() < 1e-9 && (m.bbox.max.x - x1).abs() < 1e-9, "{at}: {:?} for x {x0}..{x1}", m.bbox);
assert!((m.bbox.min.y - y0).abs() < 1e-9 && (m.bbox.max.y - y1).abs() < 1e-9, "{at}: {:?} for y {y0}..{y1}", m.bbox);
// Every face names an exact surface: four planes leaning by the taper and two flat
// caps, one of them the profile's plane at the offset. A tessellated stand-in
// measures nearly the same volume and names no surface at all.
let (mut walls, mut caps) = (0, 0);
for i in 0..b.face_count() {
match face_surface(b, i) {
Some(FaceSurface::Plane { normal, point }) if normal.z.abs() > 1.0 - 1e-9 => {
caps += 1;
assert!((point.z - lo).abs() < 1e-9 || (point.z - far).abs() < 1e-9, "{at}: a cap at z = {}", point.z);
}
Some(FaceSurface::Plane { normal, .. }) => {
walls += 1;
assert!((normal.z + sign * ang.sin()).abs() < 1e-9, "{at}: a wall leaning {}, not {}", normal.z, -sign * ang.sin());
}
other => panic!("{at}: face {i} is {other:?}"),
}
}
assert_eq!((walls, caps), (4, 2), "{at}");
}
}
// The extents that are still to come are refused, and say which they are.
for p in [json!({"distance":h,"distance2":5,"taper":"6 deg"}), json!({"distance":h,"direction":"symmetric","taper":"6 deg"})] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[w,d]}));
run(&mut s, "sketch.finish", json!({}));
let e = s.execute("solid.extrude", &p).unwrap_err().to_string();
assert!(e.contains("not supported yet: taper with two-sided or symmetric extents"), "{p}: {e}");
}
}
/// A drafted pocket that starts above its sketch plane, cut into a body three booleans deep:
/// its rim lies on a face bounded by an intersection curve, not by straight lines. The tool
/// lies wholly in material, so the cut removes exactly the frustum over its profile.
#[test]
fn a_drafted_offset_cut_into_a_body_with_history() {
use solidcraft_kernel::{FaceSurface, face_surface, measure as measure_body};
let (w, d, h, off) = (30.0, 20.0, 12.0, 18.0);
let (ang, t) = (6f64.to_radians(), 6f64.to_radians().tan());
let frustum = w * d * h + (w + d) * t * h * h + 4.0 / 3.0 * t * t * h * h * h;
let unnamed = |b: &solidcraft_kernel::Body| (0..b.face_count()).filter(|i| face_surface(b, *i).is_none()).count();
let mut s = Session::default();
// A 60 x 40 x 30 block, a round boss joined through its far side and out of its top, and a
// round pocket cut in the top: the top face is bounded by the boss's intersection curve.
run(&mut s, "solid.box", json!({"length":60,"width":40,"height":30,"corner":[0,0,0],"body_name":"P"}));
run(&mut s, "solid.cylinder", json!({"radius":8,"height":46,"base":[58,20,-8],"operation":"join"}));
run(&mut s, "solid.cylinder", json!({"radius":4,"height":12,"base":[10,34,20],"operation":"cut"}));
let before = volume(&mut s);
{
let st = s.model.state();
assert_eq!(st.bodies.len(), 1, "the block, the boss and the pocket are one body");
assert_eq!(unnamed(&st.body("P").unwrap().body), 0, "faces without a surface before the cut");
}
// The pocket: drafted 6 deg outward, starting 18 mm up the normal and out through the top.
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[5,5],"p1":[35,25]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":h,"start_offset":off,"taper":"6 deg","operation":"cut"}));
let after = volume(&mut s);
let removed = before - after;
assert!((removed - frustum).abs() < 1e-6 * frustum, "{removed} mm3 removed for the frustum's {frustum}");
let st = s.model.state();
let b = &st.body("P").unwrap().body;
let m = measure_body(b).unwrap();
assert_eq!(st.bodies.len(), 1, "one body");
assert_eq!(m.shells, 1, "one lump: the pocket is open at the top");
assert_eq!(unnamed(b), 0, "faces without a surface after the cut");
// Four drafted walls, and the pocket's floor at the offset: the only leaning planes of the
// body (the block's own faces are level or upright), and the only face in the plane the cut
// starts on. A cut keeps the tool's sides facing into the pocket, so a wall drafted outward
// by a leans by +sin a, the other way round from the same extrude as a new body.
let (mut walls, mut floor) = (0, 0);
for i in 0..b.face_count() {
let Some(FaceSurface::Plane { normal, point }) = face_surface(b, i) else { continue };
if (normal.z - ang.sin()).abs() < 1e-9 {
walls += 1;
} else if normal.z > 1.0 - 1e-9 && (point.z - off).abs() < 1e-9 {
floor += 1;
} else {
assert!(normal.z.abs() < 1e-9 || normal.z.abs() > 1.0 - 1e-9, "a plane leaning {}, neither the block's nor the pocket's", normal.z);
}
}
assert_eq!((walls, floor), (4, 1), "four drafted walls, and the pocket's floor at z = {off}");
}
/// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top
/// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front
/// face and in a knife-edge tip where it runs into the back face.
+4 -4
View File
@@ -393,8 +393,8 @@ fn limits_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result<Ve
Ok(out)
}
fn next_joint_id(s: &Session) -> u64 {
s.doc.assembly.joints.iter().map(|j| j.id).max().unwrap_or(0) + 1
fn next_joint_id(s: &Session, cmd: &str) -> Result<u64> {
super::next_free_id(cmd, s.doc.assembly.joints.iter().map(|j| j.id))
}
fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
@@ -474,7 +474,7 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
let id = next_joint_id(s);
let id = next_joint_id(s, cmd)?;
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
let offset = match p.get("offset") {
Some(v) => value(s, v, false, cmd)?,
@@ -514,7 +514,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
};
let fa = s.doc.frame_in_occurrence(oa, &w).ok_or_else(|| bad(cmd, "occurrence transform"))?;
let fb = s.doc.frame_in_occurrence(ob, &w).ok_or_else(|| bad(cmd, "occurrence transform"))?;
let id = next_joint_id(s);
let id = next_joint_id(s, cmd)?;
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Joint{id}"));
let n = kind.dofs().len();
let j = Joint {
+7
View File
@@ -287,6 +287,13 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
/// The id for a new item of a list: one past the largest id in it. Ids come from the design file,
/// so the largest may be the largest a `u64` holds, and one past that is an error, not an overflow
/// (a panic where overflow is checked, an id that is already taken where it is not).
pub(crate) fn next_free_id(cmd: &str, ids: impl Iterator<Item = u64>) -> Result<u64> {
ids.max().unwrap_or(0).checked_add(1).ok_or_else(|| crate::params::bad(cmd, "this design has no ids left to give"))
}
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
/// (`file.open` → `doc.open`). Empty when nothing is close.
+5 -5
View File
@@ -95,10 +95,10 @@ pub static COMMANDS: &[CommandSpec] = &[
const MAX_ITEMS: usize = 10_000;
fn next_id(s: &Session) -> u64 {
fn next_id(s: &Session, cmd: &str) -> Result<u64> {
let a = &s.doc.assembly;
let ids = a.contact_sets.iter().map(|c| c.id).chain(a.studies.iter().map(|m| m.id)).chain(a.exploded.iter().map(|e| e.id));
ids.max().unwrap_or(0) + 1
super::next_free_id(cmd, ids)
}
fn occurrences(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
@@ -424,7 +424,7 @@ fn new_set(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc.assembly.contact_sets.len() >= MAX_ITEMS {
return Err(bad(cmd, "too many contact sets"));
}
let id = next_id(s);
let id = next_id(s, cmd)?;
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Contact Set{}", s.doc.assembly.contact_sets.len() + 1));
let a = &mut s.doc_mut().assembly;
a.contact_sets.push(ContactSet { id, name: name.clone(), occurrences: occ, suppressed: false });
@@ -532,7 +532,7 @@ fn study(s: &mut Session, p: &Value) -> Result<Value> {
keys.push(StudyKey { joint, index, points });
}
}
let id = next_id(s);
let id = next_id(s, cmd)?;
let count = s.doc.assembly.studies.len();
let a = &mut s.doc_mut().assembly;
let out = match existing.and_then(|i| a.studies.get_mut(i)) {
@@ -952,7 +952,7 @@ fn explode_create(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc.assembly.exploded.len() >= MAX_ITEMS {
return Err(bad(cmd, "too many exploded views"));
}
let id = next_id(s);
let id = next_id(s, cmd)?;
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("Exploded View{}", s.doc.assembly.exploded.len() + 1));
s.doc_mut().assembly.exploded.push(ExplodedView { id, name: name.clone(), moves: moves.clone() });
Ok(json!({"view": id, "name": name, "moves": moves.iter().map(|(o, t)| json!({"occurrence": o, "translate": t})).collect::<Vec<_>>()}))
+1 -1
View File
@@ -56,7 +56,7 @@ fn make(s: &mut Session, p: &Value, cmd: &str, decal: bool) -> Result<Value> {
// It belongs to the active component (its plane is in that frame) and moves with it.
let component = s.active_component;
let doc = s.doc_mut();
let id = doc.canvases.iter().map(|c| c.id).max().unwrap_or(0) + 1;
let id = super::next_free_id(cmd, doc.canvases.iter().map(|c| c.id))?;
let base = if decal { "Decal" } else { "Canvas" };
let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| format!("{base}{id}"));
doc.canvases.push(Canvas {
+165 -25
View File
@@ -1,38 +1,56 @@
//! Boundary surfaces: one patch through a network of two to four sketch curves (`surface.boundary`,
//! issue #763). The kernel builds the face from the curves' own frames; this is the command's
//! parameters and its checks, so that a loop it cannot hold is refused before anything is built.
//! Boundary surfaces (`surface.boundary`, issue #763): one patch through two to four sketch
//! curves in loop order, or one surface through a network of curves in two directions (sketch
//! curves, 3D sketch curves, a body's edges, or a point for a three-sided patch). The kernel
//! builds the face; this is the command's parameters and its checks, so that what it cannot hold
//! is refused before anything is built.
use serde_json::Value;
use solidcraft_doc::{FeatureKind, SketchId};
use solidcraft_doc::{BodyName, FeatureKind, NetworkCurve, Operation, Position, SketchId};
use solidcraft_kernel::Continuity;
use super::CommandSpec;
use super::features::{add_feature, feature_sketch};
use crate::params::{bad, string_list};
use super::features::{add_feature, feature_sketch, sketch_id};
use crate::params::{bad, str_, string_list, vec3};
use crate::{Result, Session};
/// The stations a boundary carries by default (the patch's resolution along the loop).
const DEFAULT_SAMPLES: usize = 25;
const DEFAULT_SAMPLES: usize = solidcraft_doc::BOUNDARY_SAMPLES;
/// The fewest stations a patch can hold the widest continuity pair with: a G3 side needs four rows
/// at each of two opposite sides, so eight.
const MIN_SAMPLES: usize = 8;
/// The most stations a boundary may carry (the kernel's own cap).
const MAX_SAMPLES: usize = 512;
/// The most curves one direction of a network may list (the kernel's own cap).
const MAX_NETWORK_CURVES: usize = solidcraft_geom::network::MAX_NETWORK_CURVES;
/// The most sketch curves one network curve may chain, and points picking one chain of edges
/// (the kernel's own caps).
use solidcraft_kernel::{MAX_CHAIN_CURVES as MAX_CHAIN, MAX_EDGE_PICKS as MAX_PICKS};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("surface.boundary", "Boundary Surface", boundary)
.at("SURFACE", "CREATE")
.icon("surface_boundary")
.params(
"sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; the patch runs through them and leaves each boundary along the tangent across it); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary); samples?: 8…512 stations per boundary (default 25); name?, body_name? (a surface body)",
"Either a loop of one sketch's curves — sketch?: id|name (default: the sketch being edited); curves: [2 to 4 sketch curve ids in loop order] (each one ends where the next begins and the last where the first does; two: the patch spans between them and leaves each boundary along the tangent across it; three or four: the flat patch of the loop, three collapsing the side opposite the first curve to the corner there); continuity?: g0|g1|g2|g3 | [one per curve] (default g1: how much of the surface across each boundary the patch repeats — a sketch curve's own plane, so g1 and up hold exactly and g0 lets the middle bow); crown?: number -0.9…3 (default 0: how full the middle is, without touching a boundary; two curves only); samples?: 8…512 stations per boundary (default 25) — or a curve network — u: [curve] (two or more one way: the surface lofts through them), v?: [curve] (crossing every u-curve once: two each way give a Coons patch, more a Gordon surface through every curve; a curve that misses another by more than 0.01 mm is refused, naming the pair), each curve \"id\" (a curve of `sketch`) | {sketch?: id|name, curves: [ids meeting end to end]} (planar or 3D sketch curves) | {body: name, edges: [[x,y,z]] (points on a chain of its edges), continuity?: g0|g1|g2 (default g0; g1 the surface leaves tangent to the face beside a surface's open edges, g2 with its curvature too; on curves of one direction only)} | {point: [x,y,z]} (a side collapsed to a point: a three-sided patch); operation?: new|join (default new; join sews the surface into the body of its first edge curve, a solid when it closes the gap). name?, body_name? (a surface body)",
)];
/// `surface.boundary`: one patch through a network of two to four sketch curves.
/// `surface.boundary`: a loop of sketch curves, or a network when `u` is given.
fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("u").is_some_and(|v| !v.is_null()) {
return network(s, p);
}
loop_patch(s, p)
}
/// `surface.boundary` with `curves`: one patch through two to four sketch curves in loop order.
fn loop_patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.boundary";
if p.get("v").is_some_and(|v| !v.is_null()) || p.get("operation").is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, "`v` and `operation` belong to a curve network: give its curves as `u` and `v`"));
}
let sketch = feature_sketch(s, p, cmd)?;
let curves = string_list(p, "curves");
if !(2..=4).contains(&curves.len()) {
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order"));
return Err(bad(cmd, "`curves` must list 2 to 4 sketch curves in loop order (or give a network as `u` and `v`)"));
}
// The curves must be the sketch's own, and they must run on into one another: the last station
// of each is the first of the next, and the last boundary's end is the first one's start.
@@ -98,9 +116,134 @@ fn boundary(s: &mut Session, p: &Value) -> Result<Value> {
.ok_or_else(|| bad(cmd, format!("`samples` must be a whole number of stations between {MIN_SAMPLES} and {MAX_SAMPLES}")))?
}
};
if crown != 0.0 && curves.len() >= 3 {
return Err(bad(cmd, "not supported yet: `crown` on a patch through three or four curves (it is the flat patch of its sides)"));
}
add_feature(s, p, FeatureKind::SurfaceBoundary { sketch: SketchId(sketch), curves, orders, crown, samples })
}
/// `surface.boundary` with `u` (and `v`): one surface through a curve network.
fn network(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.boundary";
for key in ["curves", "continuity", "crown", "samples"] {
if p.get(key).is_some_and(|v| !v.is_null()) {
return Err(bad(cmd, format!("`{key}` belongs to a loop of sketch curves: a network gives each curve its own continuity")));
}
}
let operation = super::features::operation(p, cmd)?;
if !matches!(operation, Operation::NewBody | Operation::Join) {
return Err(bad(cmd, "`operation` must be new or join"));
}
// The sketch a bare curve id belongs to, found only when one is used.
let mut default_sketch: Option<u64> = None;
let u = network_curves(s, p, "u", &mut default_sketch)?;
let v = network_curves(s, p, "v", &mut default_sketch)?;
if u.is_empty() {
return Err(bad(cmd, "`u` must list the curves of one direction"));
}
if u.len() + v.len() < 2 {
return Err(bad(cmd, "a boundary surface needs two curves in one direction, or crossing curves"));
}
if operation == Operation::Join && !u.iter().chain(&v).any(|c| c.body.is_some()) {
return Err(bad(cmd, "`operation` join needs a curve on a body's edges: the surface is sewn into that body"));
}
add_feature(s, p, FeatureKind::SurfaceNetwork { u, v, operation })
}
/// One direction of a network (`key` is `u` or `v`), checked: each curve sketch curves that exist
/// (planar or 3D), a body's edges with points on them, or a point; a continuity only on edges.
fn network_curves(s: &Session, p: &Value, key: &str, default_sketch: &mut Option<u64>) -> Result<Vec<NetworkCurve>> {
let cmd = "surface.boundary";
let list = match p.get(key) {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::Array(a)) => a,
Some(_) => return Err(bad(cmd, format!("`{key}` must list curves"))),
};
if list.len() > MAX_NETWORK_CURVES {
return Err(bad(cmd, format!("`{key}` may list at most {MAX_NETWORK_CURVES} curves")));
}
let mut out = Vec::with_capacity(list.len());
for (k, item) in list.iter().enumerate() {
let name = format!("{key}[{k}]");
let mut curve = NetworkCurve { sketch: None, curves: Vec::new(), body: None, edges: Vec::new(), point: None, continuity: Continuity::G0 };
let obj = match item {
Value::String(id) => {
curve.curves = vec![id.clone()];
None
}
Value::Object(_) => Some(item),
_ => return Err(bad(cmd, format!("{name} must be a curve id or {{sketch?, curves}}, {{body, edges}} or {{point}}"))),
};
if let Some(o) = obj {
let has = |k: &str| o.get(k).is_some_and(|v| !v.is_null());
let kinds = [has("curves"), has("body") || has("edges"), has("point")];
if kinds.iter().filter(|x| **x).count() != 1 {
return Err(bad(cmd, format!("{name} must be one of {{sketch?, curves}}, {{body, edges}} or {{point}}")));
}
if has("point") {
curve.point = Some(Position(o.get("point").and_then(vec3).ok_or_else(|| bad(cmd, format!("{name}.point must be [x, y, z]")))?));
} else if kinds[1] {
let body = str_(o, "body").ok_or_else(|| bad(cmd, format!("{name}.body must name a body")))?;
if s.model.state().body(body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let edges =
o.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{name}.edges must list points on the edges")))?;
if edges.is_empty() || edges.len() > MAX_PICKS {
return Err(bad(cmd, format!("{name}.edges must list 1 to {MAX_PICKS} points on a chain of edges")));
}
let pts: Vec<Position> = edges
.iter()
.map(|e| vec3(e).map(Position).ok_or_else(|| bad(cmd, format!("{name}.edges: a point must be [x, y, z]"))))
.collect::<Result<_>>()?;
curve.body = Some(BodyName::from(body.to_string()));
curve.edges = pts;
} else {
curve.curves = string_list(o, "curves");
if o.get("sketch").is_some_and(|v| !v.is_null()) {
curve.sketch = Some(SketchId(sketch_id(s, o.get("sketch"), cmd, &format!("{name}.sketch"))?));
}
}
if let Some(g) = o.get("continuity").filter(|v| !v.is_null()) {
let g = g.as_str().and_then(Continuity::parse).ok_or_else(|| bad(cmd, format!("{name}.continuity must be g0, g1 or g2")))?;
if g == Continuity::G3 {
return Err(bad(cmd, format!("not supported yet: G3 on {name} (a curve network holds up to g2)")));
}
if g != Continuity::G0 && curve.body.is_none() {
return Err(bad(cmd, format!("{name}: g1 and g2 apply to a curve on a body's edges (a surface's open edges)")));
}
curve.continuity = g;
}
}
if curve.point.is_none() && curve.body.is_none() {
if curve.curves.is_empty() || curve.curves.len() > MAX_CHAIN {
return Err(bad(cmd, format!("{name} must list 1 to {MAX_CHAIN} sketch curves")));
}
let sketch = match curve.sketch {
Some(id) => id.0,
None => match *default_sketch {
Some(id) => id,
None => {
let id = feature_sketch(s, p, cmd)?;
*default_sketch = Some(id);
id
}
},
};
let state = s.model.state();
let sk = state.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch has not been solved"))?;
for id in &curve.curves {
if sk.sketch.curve_index(id).is_none() && sk.sketch.wire_index(id).is_none() {
return Err(bad(cmd, format!("{name}: no sketch curve `{id}`")));
}
}
curve.sketch = Some(SketchId(sketch));
}
out.push(curve);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
@@ -154,27 +297,19 @@ mod tests {
}
#[test]
fn a_boundary_patch_refuses_a_loop_and_values_it_cannot_hold() {
fn a_boundary_patch_refuses_values_it_cannot_hold() {
let mut s = new_sketch();
let curves = two_walls(&mut s);
let extra = ids(&run(&mut s, "sketch.line", json!({"points": [[30, 30], [40, 40]]}))["created"]["curves"])[0].clone();
// A loop of three or four: the kernel refuses it with its own reason (a tensor-product patch
// cannot hold two sides' data at a corner, issue #763) rather than building it approximately.
let triangle = ["l1", "l2", "l3"].map(|id| {
let _ = id;
String::new()
});
let _ = triangle;
// A loop of three: the crown is the bridge's, not the flat loop patch's.
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0]]}))["created"]["curves"])[0].clone();
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 0], [10, 15]]}))["created"]["curves"])[0].clone();
let c = ids(&run(&mut s, "sketch.line", json!({"points": [[10, 15], [0, 0]]}))["created"]["curves"])[0].clone();
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "continuity": "g2"})).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("#763"), "three boundaries: {e}");
// A closed square of four lines, in loop order.
let sq = [[[0, 0], [10, 0]], [[10, 0], [10, 10]], [[10, 10], [0, 10]], [[0, 10], [0, 0]]];
let square: Vec<String> = sq.iter().map(|p| ids(&run(&mut s, "sketch.line", json!({"points": p}))["created"]["curves"])[0].clone()).collect();
let e = s.execute("surface.boundary", &json!({"curves": square})).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("#763"), "four boundaries: {e}");
let e = s.execute("surface.boundary", &json!({"curves": [a, b, c], "crown": 1})).unwrap_err().to_string();
assert!(e.contains("not supported yet") && e.contains("crown"), "three boundaries with a crown: {e}");
// Curves out of loop order.
let e = s.execute("surface.boundary", &json!({"curves": [curves[0], curves[1], extra]})).unwrap_err().to_string();
assert!(e.contains("loop order"), "{e}");
// Parameters and curves that make no sense.
let cases: Vec<(Value, &str)> = vec![
(json!({"curves": [curves[0]]}), "2 to 4"),
@@ -186,6 +321,7 @@ mod tests {
(json!({"curves": curves, "crown": "full"}), "crown"),
(json!({"curves": curves, "samples": 3}), "samples"),
(json!({"curves": curves, "samples": 2.5}), "samples"),
(json!({"curves": curves, "operation": "join"}), "network"),
];
for (params, want) in cases {
let e = s.execute("surface.boundary", &params).unwrap_err().to_string();
@@ -195,3 +331,7 @@ mod tests {
assert!(s.model.state().bodies.is_empty(), "a refused patch left a body behind");
}
}
#[cfg(test)]
#[path = "surface_boundary_tests.rs"]
mod network_tests;
@@ -0,0 +1,267 @@
//! `surface.boundary` through loops of three or four sketch curves and through curve networks
//! (#763): analytic areas and shapes, G1 and G2 measured across the edge a surface shares with a
//! body (`inspect.continuity`), and refusals of hostile networks that never panic.
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 first(v: &Value) -> String {
v.as_array().and_then(|a| a.first()).and_then(Value::as_str).map(str::to_string).unwrap_or_default()
}
/// A sketch line's id.
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> String {
first(&run(s, "sketch.line", json!({"points": [a, b]}))["created"]["curves"])
}
/// A 3D sketch line's id (the active sketch must have 3D Sketch on).
fn line3d(s: &mut Session, a: [f64; 3], b: [f64; 3]) -> String {
first(&run(s, "sketch.line3d", json!({"points": [a, b]}))["curves"])
}
/// The area of the one face of `body`, and the area `inspect.measure` gives for the whole model.
fn areas(s: &mut Session, body: &str) -> (f64, f64) {
let f = run(s, "model.faces", json!({"body": body}));
let faces = f["faces"].as_array().cloned().unwrap_or_default();
assert_eq!(faces.len(), 1, "one face: {f}");
let face = faces[0]["area"].as_f64().unwrap_or(f64::NAN);
let m = run(s, "inspect.measure", json!({}));
(face, m["total"]["area_mm2"].as_f64().unwrap_or(f64::NAN))
}
/// Three and four sketch curves in loop order: the flat patch of the loop. A triangle and a square
/// of lines, and a square whose top is a half circle bulging out, each measure their own area.
#[test]
fn three_and_four_curve_loops_are_flat_patches() {
use std::f64::consts::PI;
let cases: Vec<(Vec<[[f64; 2]; 2]>, f64, f64)> = vec![
(vec![[[0.0, 0.0], [20.0, 0.0]], [[20.0, 0.0], [10.0, 15.0]], [[10.0, 15.0], [0.0, 0.0]]], 150.0, 1e-6),
(vec![[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]], 100.0, 1e-6),
];
for (lines, want, tol) in cases {
for continuity in [json!("g0"), json!("g1"), json!("g3")] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let curves: Vec<String> = lines.iter().map(|[a, b]| line(&mut s, *a, *b)).collect();
let r = run(&mut s, "surface.boundary", json!({"curves": curves, "continuity": continuity}));
let body = first(&r["created_bodies"]);
let (face, total) = areas(&mut s, &body);
assert!(
(face - want).abs() < tol * want && (total - want).abs() < tol * want,
"{} sides, {continuity}: {face} / {total} vs {want}",
lines.len()
);
}
}
// A square with a half circle for its top, bulging out: 100 + 12.5π.
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let bottom = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let right = line(&mut s, [10.0, 0.0], [10.0, 10.0]);
let arc = first(&run(&mut s, "sketch.arc.three_point", json!({"start": [10, 10], "end": [0, 10], "through": [5, 15]}))["created"]["curves"]);
let left = line(&mut s, [0.0, 10.0], [0.0, 0.0]);
let r = run(&mut s, "surface.boundary", json!({"curves": [bottom, right, arc, left]}));
let (face, total) = areas(&mut s, &first(&r["created_bodies"]));
let want = 100.0 + 12.5 * PI;
assert!((face - want).abs() < 2e-3 * want && (total - want).abs() < 2e-3 * want, "{face} / {total} vs {want}");
}
/// Simpson's rule over [0, a] × [0, a] with n (even) intervals each way.
fn simpson2(f: impl Fn(f64, f64) -> f64, a: f64, n: usize) -> f64 {
let h = a / n as f64;
let w = |k: usize| {
if k == 0 || k == n {
1.0
} else if k % 2 == 1 {
4.0
} else {
2.0
}
};
let mut sum = 0.0;
for i in 0..=n {
for j in 0..=n {
sum += w(i) * w(j) * f(i as f64 * h, j as f64 * h);
}
}
sum * h * h / 9.0
}
/// Four straight 3D sketch lines bounding the hyperbolic paraboloid z = x·y/10 over the 10 × 10
/// square: the network's Coons patch is that surface (every vertex of its mesh on it), and its
/// area is the paraboloid's, ∫∫ √(1 + (x/10)² + (y/10)²).
#[test]
fn a_network_of_3d_lines_is_a_hyperbolic_paraboloid() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.options", json!({"sketch_3d": true}));
let a = line3d(&mut s, [0.0, 0.0, 0.0], [10.0, 0.0, 0.0]);
let c = line3d(&mut s, [0.0, 10.0, 0.0], [10.0, 10.0, 10.0]);
let d = line3d(&mut s, [0.0, 0.0, 0.0], [0.0, 10.0, 0.0]);
let b = line3d(&mut s, [10.0, 0.0, 0.0], [10.0, 10.0, 10.0]);
let r = run(&mut s, "surface.boundary", json!({"u": [a, c], "v": [d, b]}));
let body = first(&r["created_bodies"]);
let want = simpson2(|x, y| (1.0 + (x / 10.0).powi(2) + (y / 10.0).powi(2)).sqrt(), 10.0, 200);
let (face, total) = areas(&mut s, &body);
// The face's own area comes from the coarse reference mesh (a chord of a fiftieth of a per
// cent of the body); the measuring mesh is refined onto the surface.
assert!((face - want).abs() < 1e-2 * want && (total - want).abs() < 1e-3 * want, "{face} / {total} vs {want}");
let st = s.world_state();
let mesh = st.body(&body).map(|b| b.body.tessellate(0.01)).and_then(Result::ok).unwrap_or_default();
assert!(!mesh.positions.is_empty());
for p in &mesh.positions {
assert!((p.z - p.x * p.y / 10.0).abs() < 1e-6, "{p:?} is off the paraboloid");
}
}
/// Curves of two planar sketches, one picked by a bare id in the sketch being edited: the loft
/// between a line on the ground and one 10 mm across and 5 mm up is the flat strip between them.
#[test]
fn a_network_takes_curves_from_several_sketches() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Low"}));
let low = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5, "name": "High"}));
let high = line(&mut s, [0.0, 10.0], [10.0, 10.0]);
let r = run(&mut s, "surface.boundary", json!({"u": [{"sketch": "Low", "curves": [low]}, high]}));
let (face, _) = areas(&mut s, &first(&r["created_bodies"]));
let want = 10.0 * 125f64.sqrt();
assert!((face - want).abs() < 1e-6 * want, "{face} vs {want}");
}
/// A quarter cylinder sheet (R = 10, axis along x, its straight top edge at y = 10, z = 0 open)
/// and a line 3D-sketched above it; returns the sheet's name and the line's id.
fn cylinder_and_line(s: &mut Session) -> (String, String) {
run(s, "sketch.create", json!({"plane": "YZ", "name": "Arc"}));
let h = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
let arc = first(&run(s, "sketch.arc.three_point", json!({"start": [0, -10], "end": [10, 0], "through": [h, -h]}))["created"]["curves"]);
run(s, "sketch.finish", json!({}));
let sheet = first(&run(s, "surface.extrude", json!({"sketch": "Arc", "curves": [arc], "distance": 10}))["created_bodies"]);
run(s, "sketch.create", json!({"plane": "XY", "name": "Top"}));
run(s, "sketch.options", json!({"sketch_3d": true}));
let top = line3d(s, [0.0, 3.0, 12.0], [10.0, 3.0, 12.0]);
run(s, "sketch.finish", json!({}));
(sheet, top)
}
/// The edge a surface built from the cylinder's top edge shares with it, measured with
/// `inspect.continuity`: (normal angle in degrees, curvature jump per mm).
fn seam(s: &mut Session, body: &str) -> (f64, f64) {
let v = run(s, "inspect.continuity", json!({"body": body, "edges": [[5.0, 10.0, 0.0]]}));
let e = v["bodies"][0]["edges"].as_array().and_then(|a| a.first()).cloned().unwrap_or_default();
(e["normal_angle_deg"]["value"].as_f64().unwrap_or(f64::NAN), e["curvature_jump_per_mm"]["value"].as_f64().unwrap_or(f64::NAN))
}
/// G1 and G2 against a body: a network from the cylinder's open top edge to a line above, joined
/// into the cylinder. Across the shared edge the normals agree within 0.1° for G1 and G2; the
/// curvature jumps by much of the cylinder's 1/R with G1 and by under 1% of it with G2.
#[test]
fn g1_and_g2_networks_meet_a_body_across_its_edge() {
let mut jumps = Vec::new();
for g in ["g1", "g2"] {
let mut s = Session::default();
let (sheet, top) = cylinder_and_line(&mut s);
let r = run(
&mut s,
"surface.boundary",
json!({"u": [{"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g}, {"sketch": "Top", "curves": [top]}], "operation": "join"}),
);
assert!(r["created_bodies"].as_array().is_none_or(Vec::is_empty), "a join makes no new body: {r}");
let f = run(&mut s, "model.faces", json!({"body": sheet}));
assert_eq!(f["faces"].as_array().map(Vec::len), Some(2), "the surface is sewn into the sheet: {f}");
let (angle, jump) = seam(&mut s, &sheet);
assert!(angle < 0.1, "{g}: normals {angle}° apart across the edge");
jumps.push(jump);
}
let r = 10.0;
assert!(jumps[0] > 0.2 / r, "G1 leaves the curvature behind: jump {}", jumps[0]);
assert!(jumps[1] < 0.01 / r, "G2 carries the cylinder's curvature across: jump {}", jumps[1]);
}
/// Networks that cannot be built are refused with a reason — by the command's own checks or by the
/// kernel — and leave nothing behind; none of them panics.
#[test]
fn hostile_networks_are_refused() {
let mut s = Session::default();
let (sheet, top) = cylinder_and_line(&mut s);
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 40, 0]}));
let boxed = s.world_state().bodies.last().map(|b| b.name.clone()).unwrap_or_default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Flat"}));
let a = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let b = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
let far = line(&mut s, [0.0, 20.0], [10.0, 20.0]);
let ring = first(&run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 3}))["created"]["curves"]);
let bodies = s.world_state().bodies.len();
let edge = |g: &str| json!({"body": sheet, "edges": [[5.0, 10.0, 0.0]], "continuity": g});
let many: Vec<Value> = (0..33).map(|_| json!(a)).collect();
let cases: Vec<(Value, &str)> = vec![
(json!({"u": "l1"}), "list"),
(json!({"u": [42, a]}), "curve id"),
(json!({"u": [{}, a]}), "one of"),
(json!({"u": [{"curves": [], "sketch": "Flat"}, a]}), "sketch curves"),
(json!({"u": [{"curves": ["nope"]}, a]}), "no sketch curve"),
(json!({"u": [{"curves": [a], "sketch": "Nope"}, b]}), "no sketch"),
(json!({"u": [{"body": "Nope", "edges": [[0, 0, 0]]}, a]}), "no body"),
(json!({"u": [{"body": sheet, "edges": []}, a]}), "edges"),
(json!({"u": [{"body": sheet, "edges": [["x", 0, 0]]}, a]}), "[x, y, z]"),
(json!({"u": [{"body": sheet, "edges": [[1e300, 0, 0]]}, a]}), "[x, y, z]"),
(json!({"u": [{"point": [0, 0]}, a]}), "point"),
(json!({"u": [{"curves": [a], "body": sheet, "edges": [[5, 10, 0]]}, b]}), "one of"),
(json!({"u": [{"curves": [a], "continuity": "g1"}, b]}), "body's edges"),
(json!({"u": [edge("g3"), {"sketch": "Top", "curves": [top]}]}), "not supported yet"),
(json!({"u": [edge("g9"), {"sketch": "Top", "curves": [top]}]}), "continuity"),
(json!({"u": many}), "at most"),
(json!({"u": [a, b], "curves": [a, b]}), "loop"),
(json!({"u": [a, b], "crown": 1}), "loop"),
(json!({"u": [a, b], "operation": "cut"}), "operation"),
(json!({"u": [a, b], "operation": "join"}), "join"),
(json!({"u": [a]}), "two curves"),
(json!({"u": [], "v": [a, b]}), "`u`"),
// The kernel's refusals: curves that miss, a closed curve, G1 on an edge between two faces.
(json!({"u": [a, b], "v": [{"curves": [far]}, {"curves": [a]}]}), "don't meet"),
(json!({"u": [{"curves": [ring]}, b]}), "closed"),
(json!({"u": [{"body": boxed, "edges": [[45, 40, 0]], "continuity": "g1"}, b]}), "open edge"),
(json!({"u": [a, {"point": [5, 2, 0]}, b]}), "point"),
];
for (params, want) in cases {
let e = match s.execute("surface.boundary", &params) {
Ok(v) => panic!("{params} was built: {v}"),
Err(e) => e,
};
assert!(!matches!(e, crate::EngineError::Internal(..)), "{params}: {e}");
assert!(e.to_string().contains(want), "{params}: {e}");
}
assert_eq!(s.world_state().bodies.len(), bodies, "a refused network left a body behind");
}
/// An edit (`timeline.redefine`) may turn a loop of sketch curves into the network of the same
/// curves and back: the feature keeps its place and its name, and the surface its area.
#[test]
fn an_edit_turns_a_loop_into_a_network_and_back() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Square"}));
let sides: Vec<String> = [[[0.0, 0.0], [10.0, 0.0]], [[10.0, 0.0], [10.0, 10.0]], [[10.0, 10.0], [0.0, 10.0]], [[0.0, 10.0], [0.0, 0.0]]]
.iter()
.map(|[a, b]| line(&mut s, *a, *b))
.collect();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "surface.boundary", json!({"sketch": "Square", "curves": sides, "name": "Patch"}));
let network = json!({"sketch": "Square", "u": [sides[0], sides[2]], "v": [sides[1], sides[3]]});
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": network}));
let kind = |s: &Session| s.doc.find_feature("Patch").map(|f| f.kind.clone());
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceNetwork { .. })), "{:?}", kind(&s));
let body = s.world_state().bodies.first().map(|b| b.name.clone()).unwrap_or_default();
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
run(&mut s, "timeline.redefine", json!({"feature": "Patch", "command": "surface.boundary", "params": {"sketch": "Square", "curves": sides}}));
assert!(matches!(kind(&s), Some(solidcraft_doc::FeatureKind::SurfaceBoundary { .. })), "{:?}", kind(&s));
assert!((areas(&mut s, &body).0 - 100.0).abs() < 1e-6);
}
+71
View File
@@ -299,3 +299,74 @@ fn a_shell_fillet_does_not_follow_a_gone_edge_onto_a_smooth_seam() {
assert!(message.contains("no longer exists"), "{message}");
assert!(!message.contains("smooth edge"), "{message}");
}
/// The faces `model.faces` lists for `body`: name, whether it is exact, and centroid.
fn listed(s: &mut Session, body: &str) -> Vec<(String, bool, [f64; 3])> {
let v = run(s, "model.faces", json!({"body": body}));
let faces = v["faces"].as_array().unwrap_or_else(|| panic!("faces of {body}: {v}"));
faces
.iter()
.map(|f| {
let c = |k: usize| f["centroid"][k].as_f64().unwrap_or(f64::NAN);
(f["name"].as_str().unwrap_or_default().to_string(), f["exact"].as_bool() == Some(true), [c(0), c(1), c(2)])
})
.collect()
}
/// The face of `faces` whose centroid is within 0.05 of `at`.
fn named_at(faces: &[(String, bool, [f64; 3])], at: [f64; 3]) -> String {
let near = |c: &[f64; 3]| (0..3).all(|k| (c[k] - at[k]).abs() < 0.05);
let found: Vec<&String> = faces.iter().filter(|(_, _, c)| near(c)).map(|(n, _, _)| n).collect();
match found.as_slice() {
[n] => (*n).clone(),
_ => panic!("faces at {at:?}: {found:?} in {faces:?}"),
}
}
/// #1209: an arm with a boss at each end (each boss the radius of the arm's round end under it)
/// and a square cut through one. The second boss and the cut are prism booleans sewn slab by
/// slab, where the arm's round end and the boss over it are two pieces of one cylinder: every
/// face keeps exact history, so the cut's walls are its own four faces and the boss's outer
/// cylinder keeps its name, through the fillets on the bore's corners too.
#[test]
fn a_cut_through_one_of_two_bosses_names_only_its_own_walls() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let (lo0, lo1) = ([16.54527384497186, -18.318724018148217], [159.66891048662592, -12.425397803491872]);
let (hi0, hi1) = ([159.66891048662592, 12.425397803491872], [16.54527384497186, 18.318724018148217]);
let (j0, j1) = ([162.79697152270225, -13.15736982664994], [162.79697152270225, 13.15736982664994]);
let (k0, k1) = ([11.818299035314524, 19.73139143375053], [11.818299035314524, -19.73139143375053]);
run(&mut s, "sketch.line", json!({"points": [lo0, lo1]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [159.91576013178314, -18.420317757309917], "start": j0, "end": lo1}));
run(&mut s, "sketch.arc.center_point", json!({"center": [170.0, 0], "start": j0, "end": j1}));
run(&mut s, "sketch.arc.center_point", json!({"center": [159.91576013178314, 18.420317757309917], "start": hi0, "end": j1}));
run(&mut s, "sketch.line", json!({"points": [hi0, hi1]}));
run(&mut s, "sketch.arc.center_point", json!({"center": [16.95668992023388, 28.310257274511628], "start": k0, "end": hi1}));
run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": k0, "end": k1}));
run(&mut s, "sketch.arc.center_point", json!({"center": [16.95668992023388, -28.310257274511628], "start": lo0, "end": k1}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 16, "body_name": "Arm"}));
run(&mut s, "solid.cylinder", json!({"diameter": 46.0, "height": 22, "base": [0, 0, 0], "operation": "join", "targets": ["Arm"]}));
let boss = named_at(&listed(&mut s, "Arm"), [19.98, -4.24, 19.0]);
assert!(boss.starts_with("F3:side"), "{boss}");
run(&mut s, "solid.cylinder", json!({"diameter": 30.0, "height": 20, "base": [170.0, 0, 0], "operation": "join", "targets": ["Arm"]}));
let joined = listed(&mut s, "Arm");
assert!(joined.iter().all(|(_, exact, _)| *exact), "{joined:?}");
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.center", json!({"center": [0, 0], "corner": [6.35, 6.35]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"through_all": true, "operation": "cut", "targets": ["Arm"]}));
let cut = listed(&mut s, "Arm");
assert!(cut.iter().all(|(_, exact, _)| *exact), "{cut:?}");
let walls: Vec<&String> = cut.iter().map(|(n, _, _)| n).filter(|n| n.starts_with("F6:")).collect();
assert_eq!(walls.len(), 4, "{cut:?}");
for (at, line) in [([0.0, -6.35, 11.0], "l1"), ([6.35, 0.0, 11.0], "l2"), ([0.0, 6.35, 11.0], "l3"), ([-6.35, 0.0, 11.0], "l4")] {
assert_eq!(named_at(&cut, at), format!("F6:side:{line}"));
}
assert_eq!(named_at(&cut, [19.98, -4.24, 19.0]), boss);
run(&mut s, "solid.fillet", json!({"edges": [[6.35, 6.35, 10], [-6.35, 6.35, 10], [-6.35, -6.35, 10], [6.35, -6.35, 10]], "radius": 1.5}));
let filleted = listed(&mut s, "Arm");
let blends = filleted.iter().filter(|(n, _, _)| n.starts_with("F7:blend:")).count();
assert_eq!(blends, 4, "{filleted:?}");
assert_eq!(named_at(&filleted, [19.98, -4.24, 19.0]), boss);
}
+53
View File
@@ -807,6 +807,8 @@ fn hostile_params_never_panic() {
"continuity",
"crown",
"faces",
"u",
"v",
];
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs()
@@ -3691,3 +3693,54 @@ fn pattern_copies_added_after_a_rename_keep_their_numbers() {
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}}));
assert_eq!(names(&s), ["X", "Second"]);
}
/// A sketch side drawn as two collinear lines extrudes into one flat face, not two with an edge
/// between them (#1788); the face is named from the first line and answers to the second.
#[test]
fn a_side_drawn_in_two_collinear_pieces_extrudes_into_one_face() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [20, 0], [20, 10], [0, 10], [0, 0]]}));
let pieces: Vec<String> = r["created"]["curves"].as_array().into_iter().flatten().take(2).filter_map(|c| c.as_str().map(String::from)).collect();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let faces = |s: &mut Session| run(s, "model.faces", json!({"body": "Body1"}))["faces"].as_array().cloned().unwrap_or_default();
let f = faces(&mut s);
assert_eq!(f.len(), 6, "a box has six faces: {f:?}");
assert_eq!(run(&mut s, "inspect.measure", json!({}))["bodies"][0]["kernel"]["faces"], 6);
let names: Vec<&str> = f.iter().filter_map(|x| x["name"].as_str()).collect();
assert!(names.contains(&format!("F2:side:{}", pieces[0]).as_str()), "{names:?}");
// The second piece's name answers for the same face, so a reference made when the side was
// two faces (an edge of the second piece) still finds its edge.
let st = s.model.state();
let body = st.body("Body1").unwrap();
let edge = format!("F2:side:{}|F2:top", pieces[1]);
let (at, _) = solidcraft_doc::naming::find_edge(body, &edge, geom::Vec3::new(15.0, 0.0, 5.0)).unwrap();
assert!(at.y.abs() < 1e-9 && (at.z - 5.0).abs() < 1e-9, "{edge} at {at:?}");
// A sketch attaches to the joined side as to any side.
assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, 0, 2.5]}}))["attachment"], "exact_face");
run(&mut s, "sketch.finish", json!({}));
}
/// A sketch made on the second piece's face while the side was two faces (as in a design saved
/// before #1788) stays attached once the side is straight and extrudes into one face.
#[test]
fn a_sketch_on_one_piece_stays_attached_when_the_side_joins() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -2], [20, 0], [20, 10], [0, 10], [0, 0]]}));
let first = r["created"]["curves"][0].as_str().unwrap_or_default().to_string();
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 5}));
let faces = |s: &mut Session| run(s, "model.faces", json!({"body": "Body1"}))["faces"].as_array().map(Vec::len).unwrap_or(0);
assert_eq!(faces(&mut s), 7);
assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, -1, 2.5]}}))["attachment"], "exact_face");
run(&mut s, "sketch.circle.center", json!({"center": [5, 2.5], "radius": 1}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
run(&mut s, "sketch.move_point", json!({"point": format!("{first}.end"), "to": [10, 0]}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(faces(&mut s), 6, "the side is straight now: one face");
let sketch = run(&mut s, "timeline.inspect", json!({"feature": "Sketch2"}));
assert!(sketch["error"].is_null() && sketch["warning"].is_null(), "{sketch}");
}
+6 -25
View File
@@ -349,20 +349,6 @@ pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3
scene_with_guides(s, cam, section, Guides::default())
}
/// The plane of the sketch that is open, if any: it always shows a grid, whatever the Grid switch
/// says (outside a sketch the grid is the user's choice).
pub fn open_sketch_plane(s: &Session) -> Option<Plane> {
let id = s.active_sketch?;
s.world_state().sketch(id).map(|ss| ss.plane)
}
/// The grid on a sketch plane: its centre (the view target's foot on the plane, snapped to the
/// major step in the plane's own axes) and the plane's x and y directions.
pub fn sketch_grid_frame(plane: &Plane, target: Vec3, major: f64) -> (Vec3, Vec3, Vec3) {
let snap = |d: Vec3| d * (((target - plane.origin).dot(d) / major).round() * major);
(plane.origin + snap(plane.x) + snap(plane.y), plane.x, plane.y)
}
/// What a headless render draws besides the model: the ground grid and the origin's axes.
/// Both are on by default; the grid lies on Z 0, so it cuts through a model that straddles it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -448,17 +434,12 @@ fn build_scene(
if guides.grid {
let (minor, major) = grid_step(cam.half_height());
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
// Headless pictures keep the grid on the ground, the viewport's default; an open sketch
// has its own plane's grid (world coordinates, no camera frame).
let (c, u, v, w): (Vec3, Vec3, Vec3, Box<dyn Fn(Vec3) -> Vec3>) = if let Some(pl) = open_sketch_plane(s) {
let (c, u, v) = sketch_grid_frame(&pl, cam.target, major);
(c, u, v, Box::new(|p| p))
} else {
let tf = cam.to_frame(cam.target);
let (u, v) = (Vec3::X, Vec3::Y);
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
(snap(u) + snap(v), u, v, Box::new(|p| cam.from_frame(p)))
};
let tf = cam.to_frame(cam.target);
// Headless pictures keep the grid on the ground, the viewport's default.
let (u, v) = (Vec3::X, Vec3::Y);
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
let c = snap(u) + snap(v);
let w = |p: Vec3| cam.from_frame(p);
let n = ((ext / minor) as i64).clamp(1, 400);
for i in -n..=n {
let t = i as f64 * minor;
+6 -2
View File
@@ -1,7 +1,8 @@
//! Geometry foundation for SolidCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
//! (B-spline basis and NURBS surfaces in [`bspline`], one-surface fills of closed loops in
//! [`fill`], T-splines in [`tspline`]). Units are millimetres.
//! (B-spline basis and NURBS surfaces in [`bspline`], spline spaces and the operators that make
//! splines compatible in [`spline_space`], one-surface fills of closed loops in [`fill`],
//! surfaces through curve networks in [`network`], T-splines in [`tspline`]). Units are millimetres.
//!
//! This crate has no knowledge of sketches, documents or a modelling backend; every other crate
//! builds on it.
@@ -19,8 +20,11 @@ pub mod fill;
pub mod fill_general;
mod mesh;
pub mod mesh_distance;
pub mod network;
mod plane;
mod profile;
pub mod section;
pub mod spline_space;
pub mod tess;
pub mod tspline;
mod vec;
File diff suppressed because it is too large. Load diff
+291
View File
@@ -0,0 +1,291 @@
//! Backend-neutral section geometry in a model-space plane, in millimetres and radians.
//!
//! Curve and wire indices identify items in this result only, not persistent model geometry.
//! Producers own source identity, topology and accuracy evidence. [`SectionGeometry::validate_structure`]
//! checks bounded payloads, references and connected wires; it does not certify simple loops,
//! winding, hole nesting, region separation, source coincidence or an approximation bound.
//! Material producers must supply CCW outer loops and CW holes in the plane's x/y basis,
//! whose normal is x cross y. Conversion preserves those supplied orientations.
//!
//! Stored coordinates are finite and at most 1e12 mm in absolute value. Radii and conic
//! weights lie in 1e-12..=1e12; arc starts in -1e6..=1e6 radians and absolute sweeps in
//! 1e-12..=one turn (with 1e-12 radians of allowance). Positive conic weights are the supported
//! subset. These arithmetic limits do not promise a small absolute error at extreme magnitudes.
//! Closure tolerance is 1e-9..=1 mm, independent of approximation accuracy.
//!
//! Validate after deserialization and before use or serialization. The DTOs deliberately retain
//! [`Plane`]'s existing serde representation and construction behavior. Validation after parsing
//! cannot bound parsing allocations: an encoded-input boundary must also limit bytes and nesting.
//! The per-wire/per-region conversions revalidate the whole result on each call; they are not an
//! optimized batch-conversion API. Existing profile area/containment methods are not general
//! exact or certified measures for conics and freeform curves. Profile values contain geometry
//! only: callers retain the original result's accuracy declarations and local identities.
use std::f64::consts::TAU;
use serde::{Deserialize, Serialize};
use crate::{Loop2, MAX_BEZIER_DEGREE, Plane, Region2, Seg2, Vec2, Vec3};
/// Aggregate input records/references, and the selected conversion's output items, at most.
/// Input counts curves, wires, regions, contacts, curve uses and hole references together.
pub const MAX_ITEMS: usize = 100_000;
const MAX_NUMBER: f64 = 1e12;
const MIN_POSITIVE: f64 = 1e-12;
const MAX_START_ANGLE: f64 = 1e6;
const FRAME_TOLERANCE: f64 = 1e-8;
/// A section's geometric result, without source identity, freshness or persistence semantics.
/// Empty, open-wire and contact-only results remain distinct; no material is inferred from them.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionGeometry {
pub plane: Plane,
pub curves: Vec<SectionCurve>,
pub wires: Vec<SectionWire>,
pub regions: Vec<SectionRegion>,
/// Isolated contacts in plane coordinates, not invented closed regions.
pub contacts: Vec<Vec2>,
}
/// One curve and the producer's declared accuracy relative to its source.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionCurve {
pub geometry: Seg2,
pub accuracy: CurveAccuracy,
}
/// How the producer represents its source; structure validation cannot certify this declaration.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CurveAccuracy {
/// An analytic representation without fitting; not a certificate about the source or topology.
Analytic,
/// A producer-established bound, not merely a requested fitting tolerance.
Bounded { max_deviation_mm: f64 },
/// A deviation measured at samples only, with no bound between them. Zero remains sampled.
Sampled { max_sample_deviation_mm: f64 },
}
/// An ordered connected chain. `closed` is explicit, never inferred or repaired.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionWire {
pub curves: Vec<CurveUse>,
pub closed: bool,
}
/// An oriented use of a curve by its result-local index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CurveUse {
pub curve: u32,
pub forward: bool,
}
/// A producer-declared material region referring to closed wires. Shared wires are permitted.
/// The outer wire cannot also be a hole, and a hole cannot occur twice in this one region.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SectionRegion {
pub outer: u32,
pub holes: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("section geometry: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
struct Budget(usize);
impl Budget {
fn add(&mut self, n: usize) -> Result<()> {
self.0 = self.0.checked_add(n).ok_or(Invalid("item budget"))?;
require(self.0 <= MAX_ITEMS, "item budget")
}
}
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= MAX_NUMBER), "non-finite or excessive coordinate")
}
fn point(p: Vec2) -> Result<()> {
numbers([p.x, p.y])
}
fn vector(p: Vec3) -> Result<()> {
numbers([p.x, p.y, p.z])
}
/// Strictly check an existing plane without re-orthonormalizing it or changing its serde format.
/// Unit-length and orthogonality checks use 1e-8; the normal is derived as x cross y.
pub fn validate_plane(plane: &Plane) -> Result<()> {
vector(plane.origin)?;
vector(plane.x)?;
vector(plane.y)?;
require(
(plane.x.len() - 1.0).abs() <= FRAME_TOLERANCE
&& (plane.y.len() - 1.0).abs() <= FRAME_TOLERANCE
&& plane.x.dot(plane.y).abs() <= FRAME_TOLERANCE,
"nonorthonormal plane",
)
}
fn controls(points: &[Vec2]) -> Result<()> {
for p in points {
point(*p)?;
}
let Some((first, rest)) = points.split_first() else { return Err(Invalid("empty controls")) };
require(rest.iter().any(|p| p != first), "collapsed curve")
}
fn curve(curve: &SectionCurve) -> Result<()> {
match curve.geometry {
Seg2::Line { a, b } => controls(&[a, b])?,
Seg2::Arc { center, radius, start, sweep } => {
point(center)?;
require(radius.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&radius), "arc radius")?;
require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?;
require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?;
}
Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3])?,
Seg2::Conic { a, apex, b, w } => {
controls(&[a, apex, b])?;
require(w.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&w), "conic weight")?;
}
Seg2::Bezier { n, ref p } => {
require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?;
// The unused slots are serialized too: reject hidden non-finite/excessive values.
for q in p {
point(*q)?;
}
controls(p.get(..=usize::from(n)).ok_or(Invalid("Bezier degree"))?)?;
}
}
require(curve.geometry.start().is_finite() && curve.geometry.end().is_finite(), "non-finite curve endpoint")?;
match curve.accuracy {
CurveAccuracy::Analytic => Ok(()),
CurveAccuracy::Bounded { max_deviation_mm } => deviation(max_deviation_mm),
CurveAccuracy::Sampled { max_sample_deviation_mm } => deviation(max_sample_deviation_mm),
}
}
fn deviation(value: f64) -> Result<()> {
require(value.is_finite() && (0.0..=MAX_NUMBER).contains(&value), "accuracy deviation")
}
impl SectionGeometry {
/// Check finite supported payloads, aggregate counts, references and wire connectivity.
/// This does not certify material validity, winding, nesting, source identity or accuracy.
/// Count preflight precedes geometry evaluation; encoded input needs separate parsing limits.
pub fn validate_structure(&self, closure_tolerance_mm: f64) -> Result<()> {
let mut budget = Budget(0);
budget.add(self.curves.len())?;
budget.add(self.wires.len())?;
budget.add(self.regions.len())?;
budget.add(self.contacts.len())?;
for wire in &self.wires {
budget.add(wire.curves.len())?;
}
for region in &self.regions {
budget.add(region.holes.len())?;
}
require(closure_tolerance_mm.is_finite() && (1e-9..=1.0).contains(&closure_tolerance_mm), "closure tolerance must be between 1e-9 and 1 mm")?;
validate_plane(&self.plane)?;
for c in &self.curves {
curve(c)?;
}
for p in &self.contacts {
point(*p)?;
}
for wire in &self.wires {
require(!wire.curves.is_empty(), "empty wire")?;
let mut first = None;
let mut last: Option<Vec2> = None;
for use_ in &wire.curves {
let c = &self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry;
let (a, b) = (c.start(), c.end());
let (start, end) = if use_.forward { (a, b) } else { (b, a) };
require(last.is_none_or(|p| p.dist(start) <= closure_tolerance_mm), "disconnected wire")?;
first.get_or_insert(start);
last = Some(end);
}
if wire.closed {
require(first.zip(last).is_some_and(|(a, b)| a.dist(b) <= closure_tolerance_mm), "open closed wire")?;
}
}
// One stamp array for all regions: neither pairwise duplicate checks nor clearing the
// entire array per region. Sharing the same wire between different regions is allowed.
let mut seen = vec![usize::MAX; self.wires.len()];
for (stamp, region) in self.regions.iter().enumerate() {
for id in std::iter::once(&region.outer).chain(&region.holes) {
let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?;
require(wire.closed, "region boundary is not closed")?;
let mark = seen.get_mut(*id as usize).ok_or(Invalid("wire reference"))?;
require(*mark != stamp, "duplicate region boundary")?;
*mark = stamp;
}
}
Ok(())
}
/// Copy one closed wire to a profile loop, preserving its oriented curve uses.
/// Revalidates the whole result; an open wire is never silently closed. Floating-point
/// reversal must also keep the converted loop connected within the same tolerance.
pub fn to_loop(&self, wire: u32, closure_tolerance_mm: f64) -> Result<Loop2> {
self.validate_structure(closure_tolerance_mm)?;
let wire = self.wires.get(wire as usize).ok_or(Invalid("wire reference"))?;
require(wire.closed, "wire is not closed")?;
let mut budget = Budget(0);
budget.add(1)?;
budget.add(wire.curves.len())?;
self.copy_loop(wire, closure_tolerance_mm)
}
/// Copy one declared region to a profile, preserving winding and hole order.
/// Revalidates the whole result and preflights expanded output before allocating it.
/// This does not certify the producer's region. The original result is unchanged; the
/// returned profile contains no accuracy declarations or result-local identities.
pub fn to_region(&self, region: u32, closure_tolerance_mm: f64) -> Result<Region2> {
self.validate_structure(closure_tolerance_mm)?;
let region = self.regions.get(region as usize).ok_or(Invalid("region reference"))?;
let mut budget = Budget(0);
budget.add(1)?;
budget.add(region.holes.len())?;
for id in std::iter::once(&region.outer).chain(&region.holes) {
let wire = self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?;
budget.add(wire.curves.len())?;
}
let outer = self.copy_loop(self.wires.get(region.outer as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?;
let mut holes = Vec::with_capacity(region.holes.len());
for id in &region.holes {
holes.push(self.copy_loop(self.wires.get(*id as usize).ok_or(Invalid("wire reference"))?, closure_tolerance_mm)?);
}
Ok(Region2 { outer, holes })
}
// Called only while the publicly validated result remains immutably borrowed and the
// selected output's expansion has been counted. Index access stays fallible nonetheless.
fn copy_loop(&self, wire: &SectionWire, closure_tolerance_mm: f64) -> Result<Loop2> {
let mut segs = Vec::with_capacity(wire.curves.len());
for use_ in &wire.curves {
let c = self.curves.get(use_.curve as usize).ok_or(Invalid("curve reference"))?.geometry;
segs.push(if use_.forward { c } else { c.reversed() });
}
let loop_ = Loop2 { segs };
// Reversing an arc changes its stored start to start + sweep. Subtracting the sweep
// at its new end need not recover the original angle exactly at large radii.
require(loop_.is_closed(closure_tolerance_mm), "converted loop is not closed within tolerance")?;
Ok(loop_)
}
}
#[cfg(test)]
#[path = "section_tests.rs"]
mod tests;
+409
View File
@@ -0,0 +1,409 @@
use std::f64::consts::PI;
use super::*;
use crate::{MAX_BEZIER_CTRL, bezier};
const TOL: f64 = 1e-6;
fn empty(plane: Plane) -> SectionGeometry {
SectionGeometry { plane, curves: vec![], wires: vec![], regions: vec![], contacts: vec![] }
}
fn analytic(geometry: Seg2) -> SectionCurve {
SectionCurve { geometry, accuracy: CurveAccuracy::Analytic }
}
fn used(curve: u32, forward: bool) -> CurveUse {
CurveUse { curve, forward }
}
fn plate() -> SectionGeometry {
let p = Vec2::new;
SectionGeometry {
plane: Plane::XY.offset(10.0),
curves: vec![
// Deliberately stored backwards: the wire's use restores the CCW rectangle.
analytic(Seg2::Line { a: p(100.0, 0.0), b: p(0.0, 0.0) }),
analytic(Seg2::Line { a: p(100.0, 0.0), b: p(100.0, 60.0) }),
analytic(Seg2::Line { a: p(100.0, 60.0), b: p(0.0, 60.0) }),
analytic(Seg2::Line { a: p(0.0, 60.0), b: p(0.0, 0.0) }),
analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: 0.0, sweep: PI }),
analytic(Seg2::Arc { center: p(50.0, 30.0), radius: 5.0, start: PI, sweep: PI }),
],
wires: vec![
SectionWire { curves: vec![used(0, false), used(1, true), used(2, true), used(3, true)], closed: true },
SectionWire { curves: vec![used(5, false), used(4, false)], closed: true },
],
regions: vec![SectionRegion { outer: 0, holes: vec![1] }],
contacts: vec![],
}
}
#[test]
fn section_frame_preserves_named_translated_tilted_offset_and_flip_conventions() {
for plane in [Plane::XY, Plane::XZ, Plane::YZ] {
validate_plane(&plane).unwrap();
}
assert_eq!(Plane::XZ.normal(), Vec3::Y);
assert_eq!(Plane::XZ.to_world(Vec2::new(3.0, 4.0)), Vec3::new(3.0, 0.0, -4.0));
let plane = Plane::from_normal(Vec3::new(13.0, -7.0, 19.0), Vec3::new(2.0, -3.0, 5.0)).unwrap();
validate_plane(&plane).unwrap();
let local = Vec2::new(7.0, -11.0);
let height = 4.5;
let point = plane.to_world(local) + plane.normal() * height;
assert!(plane.to_local(point).dist(local) < 1e-12);
assert!((plane.height(point) - height).abs() < 1e-12);
let moved = plane.offset(8.0);
validate_plane(&moved).unwrap();
assert!((plane.height(moved.origin) - 8.0).abs() < 1e-12);
for about_y in [false, true] {
let flipped = moved.flipped(about_y);
validate_plane(&flipped).unwrap();
assert_eq!(flipped.origin, moved.origin);
assert_eq!(flipped.normal(), -moved.normal());
assert_eq!(flipped.flipped(about_y), moved);
let on_plane = moved.to_world(local);
let mirrored = if about_y { Vec2::new(-local.x, local.y) } else { Vec2::new(local.x, -local.y) };
assert!(flipped.to_local(on_plane).dist(mirrored) < 1e-12);
assert!((flipped.height(point) + moved.height(point)).abs() < 1e-12);
}
}
#[test]
fn strict_frame_validation_does_not_repair_raw_or_deserialized_axes() {
let bad = Plane { x: Vec3::X * 2.0, ..Plane::XY };
assert!(Plane::new(bad.origin, bad.x, bad.y).is_some());
assert_eq!(validate_plane(&bad), Err(Invalid("nonorthonormal plane")));
assert_eq!(bad.x, Vec3::X * 2.0);
let restored: Plane = serde_json::from_value(serde_json::json!({
"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [1, 1, 0]
}))
.unwrap();
assert_eq!(validate_plane(&restored), Err(Invalid("nonorthonormal plane")));
for plane in [
Plane { origin: Vec3::new(f64::NAN, 0.0, 0.0), ..Plane::XY },
Plane { origin: Vec3::new(1e12 + 1.0, 0.0, 0.0), ..Plane::XY },
Plane { x: Vec3::new(f64::MAX, 0.0, 0.0), ..Plane::XY },
Plane { y: Vec3::new(0.0, f64::INFINITY, 0.0), ..Plane::XY },
Plane { x: Vec3::ZERO, ..Plane::XY },
Plane { y: Vec3::X, ..Plane::XY },
] {
assert!(validate_plane(&plane).is_err());
}
validate_plane(&Plane { origin: Vec3::new(1e12, -1e12, 1e12), ..Plane::XY }).unwrap();
}
#[test]
fn line_arc_region_conversion_preserves_winding_and_analytic_area() {
let section = plate();
let before = section.clone();
section.validate_structure(TOL).unwrap();
let outer = section.to_loop(0, TOL).unwrap();
let hole = section.to_loop(1, TOL).unwrap();
assert!(outer.is_closed(TOL) && hole.is_closed(TOL));
assert!((outer.signed_area() - 6000.0).abs() < 1e-10);
assert!((hole.signed_area() + 25.0 * PI).abs() < 1e-10);
let region = section.to_region(0, TOL).unwrap();
assert_eq!(region.outer, outer);
assert_eq!(region.holes, vec![hole]);
assert!((region.area() - (6000.0 - 25.0 * PI)).abs() < 1e-10);
assert_eq!(section, before);
}
#[test]
fn empty_contacts_and_open_wires_remain_distinct_without_inferred_material() {
let empty = empty(Plane::XY);
empty.validate_structure(TOL).unwrap();
assert_eq!(empty.to_region(0, TOL), Err(Invalid("region reference")));
let mut contact = empty.clone();
contact.contacts.push(Vec2::new(2.0, 3.0));
contact.validate_structure(TOL).unwrap();
assert_ne!(contact, empty);
let mut open = empty.clone();
open.curves = vec![analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::X }), analytic(Seg2::Line { a: Vec2::X, b: Vec2::new(1.0, 1.0) })];
open.wires = vec![SectionWire { curves: vec![used(0, true), used(1, true)], closed: false }];
open.validate_structure(TOL).unwrap();
assert_eq!(open.to_loop(0, TOL), Err(Invalid("wire is not closed")));
open.regions.push(SectionRegion { outer: 0, holes: vec![] });
assert_eq!(open.validate_structure(TOL), Err(Invalid("region boundary is not closed")));
// Even a geometrically closed chain remains explicitly open when the producer says so.
let mut declared_open = plate();
declared_open.regions.clear();
declared_open.wires[0].closed = false;
declared_open.validate_structure(TOL).unwrap();
assert_eq!(declared_open.to_loop(0, TOL), Err(Invalid("wire is not closed")));
}
#[test]
fn bad_references_empty_disconnected_and_unclosed_wires_fail() {
let mut section = plate();
section.wires[0].curves[0].curve = u32::MAX;
assert_eq!(section.validate_structure(TOL), Err(Invalid("curve reference")));
let mut section = plate();
section.wires[0].curves.clear();
assert_eq!(section.validate_structure(TOL), Err(Invalid("empty wire")));
let mut section = plate();
section.wires[0].curves[1].forward = false;
assert_eq!(section.validate_structure(TOL), Err(Invalid("disconnected wire")));
let mut section = plate();
section.wires[0].curves.pop();
assert_eq!(section.validate_structure(TOL), Err(Invalid("open closed wire")));
let mut section = plate();
section.regions[0].outer = u32::MAX;
assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference")));
let mut section = plate();
section.regions[0].holes[0] = u32::MAX;
assert_eq!(section.validate_structure(TOL), Err(Invalid("wire reference")));
assert_eq!(plate().to_loop(u32::MAX, TOL), Err(Invalid("wire reference")));
assert_eq!(plate().to_region(u32::MAX, TOL), Err(Invalid("region reference")));
}
#[test]
fn duplicate_boundaries_are_local_to_a_region_and_large_reference_tables_are_bounded() {
let mut section = plate();
section.regions[0].holes.push(1);
assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
let mut section = plate();
section.regions[0].holes.push(0);
assert_eq!(section.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
let mut section = plate();
section.regions.push(section.regions[0].clone());
section.validate_structure(TOL).unwrap();
assert_eq!(section.to_region(0, TOL).unwrap(), section.to_region(1, TOL).unwrap());
// Deliberately coincident boundaries: this tests a large legal reference graph, not material.
let mut many = empty(Plane::XY);
many.curves.push(analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: TAU }));
many.wires = vec![SectionWire { curves: vec![used(0, true)], closed: true }; 201];
many.regions = vec![SectionRegion { outer: 0, holes: (1..=200).collect() }; 200];
many.validate_structure(TOL).unwrap();
let last = many.regions.last_mut().unwrap();
*last.holes.last_mut().unwrap() = 1;
assert_eq!(many.validate_structure(TOL), Err(Invalid("duplicate region boundary")));
}
#[test]
fn aggregate_counts_fail_before_geometry_or_reference_inspection() {
let mut section = empty(Plane::XY);
section.contacts = vec![Vec2::new(f64::NAN, 0.0); MAX_ITEMS + 1];
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
let mut section = empty(Plane::XY);
section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS], closed: false });
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
let mut section = empty(Plane::XY);
section.regions.push(SectionRegion { outer: u32::MAX, holes: vec![u32::MAX; MAX_ITEMS] });
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
let mut section = empty(Plane::XY);
section.contacts = vec![Vec2::ZERO; MAX_ITEMS / 2];
section.wires.push(SectionWire { curves: vec![used(u32::MAX, false); MAX_ITEMS / 2], closed: false });
assert_eq!(section.validate_structure(TOL), Err(Invalid("item budget")));
// Exactly the item limit is accepted, independently of these points' material meaning.
let mut boundary = empty(Plane::XY);
boundary.contacts = vec![Vec2::ZERO; MAX_ITEMS];
boundary.validate_structure(TOL).unwrap();
let mut count = Budget(MAX_ITEMS);
assert_eq!(count.add(usize::MAX), Err(Invalid("item budget")));
}
#[test]
fn closure_tolerance_is_bounded_and_not_used_to_collapse_short_curves() {
for tolerance in [f64::NAN, f64::INFINITY, -1.0, 0.0, 1e-10, 1.01] {
assert!(plate().validate_structure(tolerance).is_err());
}
for tolerance in [1e-9, 1.0] {
plate().validate_structure(tolerance).unwrap();
}
let mut section = empty(Plane::XY);
section.curves.push(analytic(Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-10, 0.0) }));
section.validate_structure(1.0).unwrap();
let mut gap = plate();
if let Seg2::Line { b, .. } = &mut gap.curves[3].geometry {
b.x = 5e-7;
}
gap.validate_structure(1e-6).unwrap();
assert_eq!(gap.validate_structure(1e-8), Err(Invalid("open closed wire")));
}
#[test]
fn curve_number_and_domain_errors_are_rejected_before_evaluation() {
let p = Vec2::new;
let arc = |radius, start, sweep| Seg2::Arc { center: Vec2::ZERO, radius, start, sweep };
let conic = |w| Seg2::Conic { a: Vec2::X, apex: p(1.0, 1.0), b: Vec2::Y, w };
let invalid = [
Seg2::Line { a: Vec2::ZERO, b: Vec2::ZERO },
Seg2::Line { a: Vec2::ZERO, b: p(f64::NAN, 0.0) },
Seg2::Line { a: Vec2::ZERO, b: p(f64::MAX, 0.0) },
arc(-1.0, 0.0, PI),
arc(0.0, 0.0, PI),
arc(1e-13, 0.0, PI),
arc(1e12 + 1.0, 0.0, PI),
arc(f64::INFINITY, 0.0, PI),
arc(1.0, f64::NAN, PI),
arc(1.0, 1e6 + 1.0, PI),
arc(1.0, 0.0, 0.0),
arc(1.0, 0.0, 1e-13),
arc(1.0, 0.0, TAU + 1e-8),
arc(1.0, 0.0, f64::INFINITY),
conic(-0.5),
conic(0.0),
conic(1e-13),
conic(1e12 + 1.0),
conic(f64::NAN),
Seg2::Cubic { p0: Vec2::X, p1: Vec2::X, p2: Vec2::X, p3: Vec2::X },
Seg2::Conic { a: Vec2::X, apex: Vec2::X, b: Vec2::X, w: 1.0 },
];
for geometry in invalid {
let mut section = empty(Plane::XY);
section.curves.push(analytic(geometry));
assert!(section.validate_structure(TOL).is_err(), "accepted {geometry:?}");
}
let mut section = empty(Plane::XY);
section.contacts.push(p(f64::INFINITY, 0.0));
assert!(section.validate_structure(TOL).is_err());
}
#[test]
fn bezier_degree_and_every_serialized_control_slot_are_validated() {
for degree in [4u8, 9] {
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
for (i, point) in p.iter_mut().enumerate().take(usize::from(degree) + 1) {
*point = Vec2::new(i as f64, (i % 2) as f64);
}
let mut section = empty(Plane::XY);
section.curves.push(analytic(Seg2::Bezier { n: degree, p }));
section.validate_structure(TOL).unwrap();
for wrong in [0, 3, 10, u8::MAX] {
section.curves[0].geometry = Seg2::Bezier { n: wrong, p };
assert_eq!(section.validate_structure(TOL), Err(Invalid("Bezier degree")));
}
}
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
p[4] = Vec2::X;
for value in [f64::NAN, f64::INFINITY, 1e12 + 1.0] {
p[MAX_BEZIER_CTRL - 1] = Vec2::new(value, 0.0);
let mut section = empty(Plane::XY);
section.curves.push(analytic(Seg2::Bezier { n: 4, p }));
assert_eq!(section.validate_structure(TOL), Err(Invalid("non-finite or excessive coordinate")));
}
let mut section = empty(Plane::XY);
section.curves.push(analytic(Seg2::Bezier { n: 4, p: [Vec2::ZERO; MAX_BEZIER_CTRL] }));
assert_eq!(section.validate_structure(TOL), Err(Invalid("collapsed curve")));
}
#[test]
fn supported_curve_limits_keep_evaluation_finite_without_claiming_accuracy() {
let p = Vec2::new;
let mut section = empty(Plane::XY);
section.curves = vec![
analytic(Seg2::Line { a: p(-1e12, 1e12), b: p(1e12, -1e12) }),
analytic(Seg2::Arc { center: p(1e12, 1e12), radius: 1e12, start: 1e6, sweep: -TAU }),
analytic(Seg2::Arc { center: Vec2::ZERO, radius: 1e-12, start: 0.0, sweep: 1e-12 }),
analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e12 }),
analytic(Seg2::Conic { a: p(-1e12, 0.0), apex: p(0.0, 1e12), b: p(1e12, 0.0), w: 1e-12 }),
analytic(bezier(&[p(0.0, 0.0), p(1e12, 0.0), p(0.0, 1e12), p(-1e12, 0.0)]).unwrap()),
];
let controls: Vec<Vec2> = (0..=9).map(|i| p(i as f64 * 1e11, if i % 2 == 0 { 1e12 } else { -1e12 })).collect();
section.curves.push(analytic(bezier(&controls).unwrap()));
section.validate_structure(TOL).unwrap();
for curve in &section.curves {
for t in [0.0, 0.5, 1.0] {
assert!(curve.geometry.point_at(t).is_finite());
assert!(curve.geometry.derivative(t).is_finite());
assert!(curve.geometry.derivatives(t, 4).is_some_and(|d| d.iter().all(|v| v.is_finite())));
}
}
}
#[test]
fn transient_serialization_preserves_accuracy_tags_and_plane_names() {
let mut section = plate();
section.curves[1].accuracy = CurveAccuracy::Bounded { max_deviation_mm: 0.01 };
section.curves[2].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 };
section.validate_structure(TOL).unwrap();
let value = serde_json::to_value(&section).unwrap();
assert!(value["plane"].get("x_dir").is_some());
assert!(value["plane"].get("y_dir").is_some());
assert_eq!(value["curves"][0]["accuracy"], serde_json::json!({"kind": "analytic"}));
assert_eq!(value["curves"][1]["accuracy"], serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.01}));
assert_eq!(value["curves"][2]["accuracy"], serde_json::json!({"kind": "sampled", "max_sample_deviation_mm": 0.0}));
let restored: SectionGeometry = serde_json::from_value(value.clone()).unwrap();
restored.validate_structure(TOL).unwrap();
assert_eq!(section, restored);
let mut unknown = value;
unknown["certificate"] = serde_json::json!(true);
assert!(serde_json::from_value::<SectionGeometry>(unknown).is_err());
assert!(serde_json::from_value::<CurveAccuracy>(serde_json::json!({"kind": "bounded", "max_deviation_mm": 0.1, "exact": true})).is_err());
assert!(serde_json::from_value::<CurveAccuracy>(serde_json::json!({"kind": "exact"})).is_err());
}
#[test]
fn accuracy_values_are_validated_without_promoting_zero_sampled_error() {
for value in [f64::NAN, f64::INFINITY, -0.01, 1e12 + 1.0] {
for accuracy in [CurveAccuracy::Bounded { max_deviation_mm: value }, CurveAccuracy::Sampled { max_sample_deviation_mm: value }] {
let mut section = plate();
section.curves[0].accuracy = accuracy;
assert_eq!(section.validate_structure(TOL), Err(Invalid("accuracy deviation")));
}
}
for value in [0.0, 1e12] {
let mut section = plate();
section.curves[0].accuracy = CurveAccuracy::Sampled { max_sample_deviation_mm: value };
section.validate_structure(TOL).unwrap();
section.to_region(0, TOL).unwrap();
assert_eq!(section.curves[0].accuracy, CurveAccuracy::Sampled { max_sample_deviation_mm: value });
}
}
#[test]
fn conversion_revalidates_mutated_data_and_does_not_trust_an_earlier_success() {
let mut section = plate();
section.validate_structure(TOL).unwrap();
section.curves[0].geometry = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(f64::NAN, 0.0) };
assert_eq!(section.to_loop(0, TOL), Err(Invalid("non-finite or excessive coordinate")));
assert_eq!(section.to_region(0, TOL), Err(Invalid("non-finite or excessive coordinate")));
let mut section = plate();
section.to_region(0, TOL).unwrap();
section.contacts = vec![Vec2::ZERO; MAX_ITEMS];
assert_eq!(section.to_region(0, TOL), Err(Invalid("item budget")));
}
#[test]
fn reversed_large_arc_conversion_rechecks_actual_connectivity_without_snapping() {
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1e12, start: 0.1, sweep: 1.0 };
let mut section = empty(Plane::XY);
section.curves = vec![analytic(arc), analytic(Seg2::Line { a: arc.start(), b: arc.end() })];
section.wires.push(SectionWire { curves: vec![used(0, false), used(1, true)], closed: true });
section.regions.push(SectionRegion { outer: 0, holes: vec![] });
let before = section.clone();
// Source endpoints coincide exactly, but (start + sweep) - sweep changes the reversed
// arc's endpoint by more than this tolerance at this radius.
section.validate_structure(1e-9).unwrap();
assert!(arc.reversed().end().dist(arc.start()) > 1e-9);
assert_eq!(section.to_loop(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance")));
assert_eq!(section.to_region(0, 1e-9), Err(Invalid("converted loop is not closed within tolerance")));
assert_eq!(section, before);
// A caller's explicit larger tolerance can accept the same result without moving endpoints.
let converted = section.to_loop(0, 0.01).unwrap();
assert_eq!(converted.segs[0], arc.reversed());
assert!(converted.is_closed(0.01));
}
#[test]
fn connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired() {
let p = Vec2::new;
let loop_ = Loop2::polygon(&[p(0.0, 0.0), p(2.0, 2.0), p(0.0, 2.0), p(2.0, 0.0)]);
let mut bow = empty(Plane::XY);
bow.curves = loop_.segs.iter().copied().map(analytic).collect();
bow.wires.push(SectionWire { curves: (0..4).map(|i| used(i, true)).collect(), closed: true });
bow.regions.push(SectionRegion { outer: 0, holes: vec![] });
bow.validate_structure(TOL).unwrap();
assert_eq!(bow.to_loop(0, TOL).unwrap(), loop_);
// A successful structural check does not establish a material region, including its winding.
let mut reversed = plate();
reversed.wires[0].curves.reverse();
for use_ in &mut reversed.wires[0].curves {
use_.forward = !use_.forward;
}
reversed.validate_structure(TOL).unwrap();
let converted = reversed.to_region(0, TOL).unwrap();
assert!((converted.outer.signed_area() + 6000.0).abs() < 1e-10);
}
+584
View File
@@ -0,0 +1,584 @@
//! Spline function spaces and the linear operators surface construction is built from: a clamped
//! B-spline space ([`Space`]), interpolation in it with derivative (Hermite) conditions
//! ([`Interpolant`]), carrying a spline into a larger space that contains it ([`embed`]: Bézier
//! degree elevation and Boehm knot insertion, exact), and a monotone piecewise cubic through
//! increasing data ([`Monotone`], for reparameterising curves).
//!
//! These are the pieces that make several splines *compatible* (one degree, one knot vector) so
//! that their control points can be combined linearly: lofting, Coons and Gordon surfaces, and
//! cross-boundary derivative conditions all reduce to them.
//!
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §2.2–2.3 (basis functions and
//! their derivatives), §5.2 (knot insertion), §5.5 (degree elevation; the Bézier case), §9.2.1
//! (global interpolation, knots by averaging the parameters) and §9.2.2 (interpolation with
//! end derivatives); Fritsch & Carlson 1980, "Monotone piecewise cubic interpolation" (SIAM J.
//! Numer. Anal. 17), with the weighted harmonic-mean slopes of Fritsch & Butland 1984.
//!
//! Input is hostile: constructors check sizes and finiteness and return [`SplineError`]; no
//! function here panics.
use crate::Vec3;
use crate::bspline::{HPoint, MAX_SPLINE_DEGREE, Side, SplineError, SplineResult, check_knots, find_span, insert_knot, invalid, local_basis};
/// Most basis functions a space used for interpolation may have (its systems are solved densely).
pub const MAX_SPACE_DIMENSION: usize = 2048;
/// Values a spline can carry: numbers and points (vectors).
pub trait Linear: Copy {
fn zero() -> Self;
fn plus(self, o: Self) -> Self;
fn times(self, s: f64) -> Self;
/// The value as a homogeneous point of weight one (knot insertion works on those).
fn to_h(self) -> HPoint;
fn from_h(h: HPoint) -> Self;
fn is_finite(self) -> bool;
}
impl Linear for f64 {
fn zero() -> Self {
0.0
}
fn plus(self, o: Self) -> Self {
self + o
}
fn times(self, s: f64) -> Self {
self * s
}
fn to_h(self) -> HPoint {
HPoint { x: self, y: 0.0, z: 0.0, w: 1.0 }
}
fn from_h(h: HPoint) -> Self {
h.x
}
fn is_finite(self) -> bool {
f64::is_finite(self)
}
}
impl Linear for Vec3 {
fn zero() -> Self {
Vec3::ZERO
}
fn plus(self, o: Self) -> Self {
self + o
}
fn times(self, s: f64) -> Self {
self * s
}
fn to_h(self) -> HPoint {
HPoint::weighted(self, 1.0)
}
fn from_h(h: HPoint) -> Self {
h.xyz()
}
fn is_finite(self) -> bool {
Vec3::is_finite(self)
}
}
/// A clamped B-spline space: a degree and a knot vector whose first and last `degree + 1` knots
/// are the domain's ends, interior knots repeated at most `degree` times (the space's splines
/// are continuous).
#[derive(Clone, Debug, PartialEq)]
pub struct Space {
degree: usize,
knots: Vec<f64>,
}
impl Space {
pub fn new(degree: usize, knots: Vec<f64>) -> SplineResult<Space> {
if degree == 0 || degree > MAX_SPLINE_DEGREE {
return Err(SplineError::Unsupported(format!("degree {degree} (1 to {MAX_SPLINE_DEGREE})")));
}
check_knots(&knots)?;
let n = knots.len().checked_sub(degree + 1).filter(|n| *n > degree).ok_or_else(|| SplineError::Invalid("too few knots".into()))?;
if n > MAX_SPACE_DIMENSION {
return Err(SplineError::TooLarge(format!("{n} basis functions (at most {MAX_SPACE_DIMENSION})")));
}
let (a, b) = (knots[0], knots[knots.len() - 1]);
if !(a < b) || knots[..=degree].iter().any(|k| *k != a) || knots[n..].iter().any(|k| *k != b) {
return invalid("the knot vector must be clamped on a non-empty domain");
}
let inner = &knots[degree + 1..n];
let mut run = 0;
for w in inner.windows(2) {
run = if w[0] == w[1] { run + 1 } else { 0 };
if run + 1 > degree {
return invalid("an interior knot is repeated more often than the degree");
}
}
if inner.first().is_some_and(|k| *k == a) || inner.last().is_some_and(|k| *k == b) {
return invalid("an interior knot lies on a domain end");
}
Ok(Space { degree, knots })
}
/// Polynomials of `degree` on `[a, b]` (one Bézier piece).
pub fn polynomial(degree: usize, a: f64, b: f64) -> SplineResult<Space> {
let mut knots = vec![a; degree + 1];
knots.extend(std::iter::repeat_n(b, degree + 1));
Space::new(degree, knots)
}
/// Splines of `degree` with a simple knot at every interior break: `breaks` increase
/// strictly, the first and last are the domain's ends.
pub fn with_breaks(degree: usize, breaks: &[f64]) -> SplineResult<Space> {
let (Some(&a), Some(&b)) = (breaks.first(), breaks.last()) else { return invalid("no breaks") };
if breaks.len() > MAX_SPACE_DIMENSION || breaks.windows(2).any(|w| !(w[0] < w[1])) {
return invalid("the breaks must increase");
}
let mut knots = vec![a; degree + 1];
knots.extend(breaks.iter().skip(1).take(breaks.len().saturating_sub(2)).copied());
knots.extend(std::iter::repeat_n(b, degree + 1));
Space::new(degree, knots)
}
pub fn degree(&self) -> usize {
self.degree
}
pub fn knots(&self) -> &[f64] {
&self.knots
}
/// The number of basis functions (control points of a spline in the space).
pub fn count(&self) -> usize {
self.knots.len() - self.degree - 1
}
/// The domain `[a, b]`.
pub fn domain(&self) -> [f64; 2] {
[self.knots[0], self.knots[self.knots.len() - 1]]
}
/// The `der`-th derivatives at `t` (clamped to the domain) of the basis functions that do not
/// vanish there: the index of the first one and their values.
pub fn basis(&self, t: f64, der: usize) -> SplineResult<(usize, Vec<f64>)> {
let [a, b] = self.domain();
if !t.is_finite() {
return invalid("a parameter is not a finite number");
}
let t = t.clamp(a, b);
let (p, n) = (self.degree, self.count());
let span = find_span(&self.knots, p, n, t).ok_or_else(|| SplineError::Invalid(format!("{t} is outside the domain")))?;
let side = if t >= b { Side::Left } else { Side::Right };
let first = span - p;
let values =
(first..=span).map(|i| self.knots.get(i..i + p + 2).map_or(0.0, |k| local_basis(k, t, side, der).get(der).copied().unwrap_or(0.0)));
Ok((first, values.collect()))
}
/// The `der`-th derivative at `t` of the spline with control values `ctrl`.
pub fn eval<T: Linear>(&self, ctrl: &[T], t: f64, der: usize) -> SplineResult<T> {
if ctrl.len() != self.count() {
return invalid(format!("{} control values for {} basis functions", ctrl.len(), self.count()));
}
let (first, values) = self.basis(t, der)?;
Ok(values.iter().zip(ctrl.iter().skip(first)).fold(T::zero(), |acc, (n, c)| acc.plus(c.times(*n))))
}
/// The splines of this space with `breaks` added as simple knots (those already knots are
/// left as they are). The result contains this space.
pub fn refined(&self, breaks: &[f64]) -> SplineResult<Space> {
let [a, b] = self.domain();
let mut knots = self.knots.clone();
for &x in breaks {
if x > a && x < b && !knots.contains(&x) {
knots.push(x);
}
}
knots.sort_by(f64::total_cmp);
Space::new(self.degree, knots)
}
}
/// A condition on a spline: the value (`der` = 0) or a derivative at `t`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Cond {
pub t: f64,
pub der: usize,
}
/// Interpolation in a space: the spline whose values and derivatives at the conditions are given
/// (as many conditions as the space has basis functions). The system is factored once; each
/// [`Interpolant::fit`] solves it for new data, so one interpolant serves many curves and the
/// rows of a surface alike (the operator is linear).
#[derive(Clone, Debug)]
pub struct Interpolant {
space: Space,
conds: Vec<Cond>,
lu: Lu,
}
impl Interpolant {
pub fn new(space: Space, conds: Vec<Cond>) -> SplineResult<Interpolant> {
let n = space.count();
if conds.len() != n {
return invalid(format!("{} conditions for {n} basis functions", conds.len()));
}
let mut a = vec![0.0; n * n];
for (r, c) in conds.iter().enumerate() {
let (first, values) = space.basis(c.t, c.der)?;
for (k, v) in values.iter().enumerate() {
if let Some(x) = a.get_mut(r * n + first + k) {
*x = *v;
}
}
}
let lu = Lu::new(n, a).map_err(|_| SplineError::Invalid("the interpolation conditions do not determine a spline".into()))?;
Ok(Interpolant { space, conds, lu })
}
/// Interpolation by a single polynomial piece of the lowest degree that meets `conds` (degree
/// one less than their number, at least one: a lone value is held constant), on `[a, b]`;
/// with more than four conditions, a cubic spline whose interior knots average the
/// conditions' parameters (taken as a sequence with one entry per condition, NURBS Book eq.
/// 9.8 applied to that confluent sequence, which keeps every basis function's support over
/// the condition it is matched with).
pub fn lowest(conds: Vec<Cond>, a: f64, b: f64) -> SplineResult<Interpolant> {
let mut conds = conds;
if conds.iter().any(|c| !c.t.is_finite() || c.t < a || c.t > b) {
return invalid("an interpolation parameter is outside the domain");
}
if conds.len() == 1
&& let Some(c) = conds.first().copied()
{
// A constant: the value and a zero slope (the caller supplies zero data for it).
conds.push(Cond { t: c.t, der: 1 });
}
let k = conds.len();
if k <= 4 {
return Interpolant::new(Space::polynomial(k.saturating_sub(1).max(1), a, b)?, conds);
}
let mut tau: Vec<f64> = conds.iter().map(|c| c.t).collect();
tau.sort_by(f64::total_cmp);
let mut breaks = vec![a];
for j in 1..k - 3 {
breaks.push((tau[j] + tau[j + 1] + tau[j + 2]) / 3.0);
}
breaks.push(b);
if breaks.windows(2).any(|w| !(w[0] < w[1])) {
return invalid("the interpolation parameters repeat too often");
}
Interpolant::new(Space::with_breaks(3, &breaks)?, conds)
}
pub fn space(&self) -> &Space {
&self.space
}
pub fn conds(&self) -> &[Cond] {
&self.conds
}
/// The control values of the spline meeting the conditions with `data` (one per condition).
pub fn fit<T: Linear>(&self, data: &[T]) -> SplineResult<Vec<T>> {
if data.len() != self.conds.len() {
return invalid(format!("{} values for {} conditions", data.len(), self.conds.len()));
}
let out = self.lu.solve(data);
if out.iter().any(|x| !x.is_finite()) {
return invalid("interpolation did not give finite control values");
}
Ok(out)
}
}
/// An LU factorisation with partial pivoting of a dense square matrix (row-major).
#[derive(Clone, Debug)]
struct Lu {
n: usize,
a: Vec<f64>,
piv: Vec<usize>,
}
impl Lu {
fn new(n: usize, mut a: Vec<f64>) -> SplineResult<Lu> {
if a.len() != n * n {
return invalid("matrix size");
}
let scale = a.iter().fold(0.0f64, |m, x| m.max(x.abs()));
if !(scale > 0.0 && scale.is_finite()) {
return invalid("singular system");
}
let mut piv = vec![0; n];
for k in 0..n {
let (mut best, mut big) = (k, 0.0);
for r in k..n {
let x = a[r * n + k].abs();
if x > big {
(best, big) = (r, x);
}
}
if !(big > scale * 1e-13) {
return invalid("singular system");
}
piv[k] = best;
if best != k {
for c in 0..n {
a.swap(k * n + c, best * n + c);
}
}
let d = a[k * n + k];
for r in k + 1..n {
let f = a[r * n + k] / d;
a[r * n + k] = f;
if f != 0.0 {
for c in k + 1..n {
a[r * n + c] -= f * a[k * n + c];
}
}
}
}
Ok(Lu { n, a, piv })
}
fn solve<T: Linear>(&self, b: &[T]) -> Vec<T> {
let n = self.n;
let mut x = b.to_vec();
if x.len() != n {
return x;
}
for (k, &p) in self.piv.iter().enumerate() {
x.swap(k, p);
}
for r in 0..n {
let row = &self.a[r * n..r * n + r];
let acc = row.iter().zip(&x[..r]).fold(x[r], |acc, (l, xc)| acc.plus(xc.times(-l)));
x[r] = acc;
}
for r in (0..n).rev() {
let row = &self.a[r * n + r + 1..(r + 1) * n];
let acc = row.iter().zip(&x[r + 1..]).fold(x[r], |acc, (u, xc)| acc.plus(xc.times(-u)));
x[r] = acc.times(1.0 / self.a[r * n + r]);
}
x
}
}
/// The control values in `to` of the spline with control values `ctrl` in `from`, which `to`
/// contains: the same domain, a degree at least as high, and every knot of `from` among those of
/// `to` (with a polynomial `from` when the degree rises). Exact: Bézier degree elevation (NURBS
/// Book §5.5) to the target degree, then Boehm knot insertion (§5.2) of the knots `to` adds.
pub fn embed<T: Linear>(from: &Space, ctrl: &[T], to: &Space) -> SplineResult<Vec<T>> {
if ctrl.len() != from.count() {
return invalid("control values do not match the space");
}
let ([a0, b0], [a1, b1]) = (from.domain(), to.domain());
if a0 != a1 || b0 != b1 {
return invalid("the spaces' domains differ");
}
let mut p = from.degree();
let mut knots = from.knots().to_vec();
let mut pts: Vec<T> = ctrl.to_vec();
if p > to.degree() {
return invalid("the target space's degree is lower");
}
if p < to.degree() {
if from.count() != p + 1 {
return Err(SplineError::Unsupported("raising the degree of a spline with interior knots".into()));
}
while p < to.degree() {
pts = elevate_bezier(&pts);
p += 1;
}
knots = vec![a0; p + 1];
knots.extend(std::iter::repeat_n(b0, p + 1));
}
// The knots `to` adds: a merge of the two sorted vectors.
let target = to.knots();
let mut missing = Vec::new();
let mut j = 0;
for &x in target {
match knots.get(j) {
Some(&k) if k == x => j += 1,
Some(&k) if k < x => return invalid("a knot of the spline is not in the target space"),
_ => missing.push(x),
}
}
if j != knots.len() {
return invalid("a knot of the spline is not in the target space");
}
let mut h: Vec<HPoint> = pts.iter().map(|x| x.to_h()).collect();
for x in missing {
(knots, h) = insert_knot(p, &knots, &h, x)?;
}
if knots.as_slice() != target {
return invalid("knot insertion did not reach the target space");
}
Ok(h.into_iter().map(T::from_h).collect())
}
/// A Bézier piece's control values one degree higher, the same polynomial (NURBS Book eq. 5.36):
/// `Qᵢ = (i/(p+1))·Pᵢ₋₁ + (1 − i/(p+1))·Pᵢ`.
fn elevate_bezier<T: Linear>(pts: &[T]) -> Vec<T> {
let p = pts.len().saturating_sub(1);
let at = |i: usize| pts.get(i).copied().unwrap_or_else(T::zero);
(0..=p + 1)
.map(|i| {
let a = i as f64 / (p + 1) as f64;
let prev = if i == 0 { T::zero() } else { at(i - 1).times(a) };
prev.plus(at(i).times(1.0 - a))
})
.collect()
}
/// A monotone piecewise cubic Hermite function through `(xₖ, yₖ)`, both strictly increasing
/// (Fritsch & Carlson): slopes are the weighted harmonic means of the neighbouring secants
/// (Fritsch & Butland), the secant at the ends. Its slope is positive everywhere, and through two
/// points it is the straight line.
#[derive(Clone, Debug, PartialEq)]
pub struct Monotone {
x: Vec<f64>,
y: Vec<f64>,
m: Vec<f64>,
}
impl Monotone {
pub fn new(x: Vec<f64>, y: Vec<f64>) -> SplineResult<Monotone> {
let n = x.len();
if n < 2 || y.len() != n {
return invalid("a monotone cubic needs two points or more");
}
let increasing = |v: &[f64]| v.iter().all(|t| t.is_finite()) && v.windows(2).all(|w| w[0] < w[1]);
if !increasing(&x) || !increasing(&y) {
return invalid("the points of a monotone cubic must increase");
}
let h: Vec<f64> = x.windows(2).map(|w| w[1] - w[0]).collect();
let d: Vec<f64> = y.windows(2).zip(&h).map(|(w, h)| (w[1] - w[0]) / h).collect();
let mut m = Vec::with_capacity(n);
m.push(d[0]);
for k in 1..n - 1 {
let (w1, w2) = (2.0 * h[k] + h[k - 1], h[k] + 2.0 * h[k - 1]);
m.push((w1 + w2) / (w1 / d[k - 1] + w2 / d[k]));
}
m.push(d[n - 2]);
Ok(Monotone { x, y, m })
}
/// The value and the first two derivatives at `t` (clamped to the points' range).
pub fn eval(&self, t: f64) -> [f64; 3] {
let n = self.x.len();
let t = t.clamp(self.x[0], self.x[n - 1]);
let k = self.x.partition_point(|x| *x <= t).saturating_sub(1).min(n - 2);
let (x0, x1, y0, y1, m0, m1) = (self.x[k], self.x[k + 1], self.y[k], self.y[k + 1], self.m[k], self.m[k + 1]);
let h = x1 - x0;
let s = (t - x0) / h;
let (s2, s3) = (s * s, s * s * s);
let value = (2.0 * s3 - 3.0 * s2 + 1.0) * y0 + (s3 - 2.0 * s2 + s) * h * m0 + (-2.0 * s3 + 3.0 * s2) * y1 + (s3 - s2) * h * m1;
let d1 = ((6.0 * s2 - 6.0 * s) * y0 + (3.0 * s2 - 4.0 * s + 1.0) * h * m0 + (-6.0 * s2 + 6.0 * s) * y1 + (3.0 * s2 - 2.0 * s) * h * m1) / h;
let d2 = ((12.0 * s - 6.0) * y0 + (6.0 * s - 4.0) * h * m0 + (-12.0 * s + 6.0) * y1 + (6.0 * s - 2.0) * h * m1) / (h * h);
[value, d1, d2]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-10
}
/// Hermite interpolation reproduces a cubic from its values and end derivatives, and the
/// lowest-degree interpolant through two values is the straight line.
#[test]
fn interpolation_meets_values_and_derivatives() {
let f = |t: f64| 1.0 + 2.0 * t - 3.0 * t * t + 0.5 * t * t * t;
let df = |t: f64| 2.0 - 6.0 * t + 1.5 * t * t;
let d2f = |t: f64| -6.0 + 3.0 * t;
let conds = vec![Cond { t: 0.0, der: 0 }, Cond { t: 0.0, der: 1 }, Cond { t: 0.0, der: 2 }, Cond { t: 2.0, der: 0 }];
let it = Interpolant::lowest(conds, 0.0, 2.0).unwrap();
let c = it.fit(&[f(0.0), df(0.0), d2f(0.0), f(2.0)]).unwrap();
for k in 0..=10 {
let t = k as f64 * 0.2;
assert!(close(it.space().eval(&c, t, 0).unwrap(), f(t)), "{t}");
}
let line = Interpolant::lowest(vec![Cond { t: 0.0, der: 0 }, Cond { t: 1.0, der: 0 }], 0.0, 1.0).unwrap();
let c = line.fit(&[2.0, 5.0]).unwrap();
assert!(close(line.space().eval(&c, 0.25, 0).unwrap(), 2.75));
let constant = Interpolant::lowest(vec![Cond { t: 0.3, der: 0 }], 0.0, 1.0).unwrap();
let c = constant.fit(&[4.0, 0.0]).unwrap();
assert!(close(constant.space().eval(&c, 0.9, 0).unwrap(), 4.0));
}
/// Many conditions: a cubic spline with averaged knots through samples of a smooth function
/// with derivative conditions at both ends (values exact at the samples).
#[test]
fn spline_interpolation_with_end_derivatives() {
let f = |t: f64| (2.0 * t).sin();
let df = |t: f64| 2.0 * (2.0 * t).cos();
let ts: Vec<f64> = (0..=8).map(|k| k as f64 / 8.0).collect();
let mut conds: Vec<Cond> = ts.iter().map(|&t| Cond { t, der: 0 }).collect();
conds.push(Cond { t: 0.0, der: 1 });
conds.push(Cond { t: 1.0, der: 1 });
let it = Interpolant::lowest(conds, 0.0, 1.0).unwrap();
let mut data: Vec<f64> = ts.iter().map(|&t| f(t)).collect();
data.push(df(0.0));
data.push(df(1.0));
let c = it.fit(&data).unwrap();
for &t in &ts {
assert!(close(it.space().eval(&c, t, 0).unwrap(), f(t)));
}
assert!(close(it.space().eval(&c, 0.0, 1).unwrap(), df(0.0)));
assert!(close(it.space().eval(&c, 1.0, 1).unwrap(), df(1.0)));
assert!((it.space().eval(&c, 0.53, 0).unwrap() - f(0.53)).abs() < 1e-4);
}
/// Embedding is exact: a quadratic polynomial raised to a cubic with knots inserted, and a
/// cubic spline refined, evaluate the same everywhere; a space that does not contain the
/// spline is refused.
#[test]
fn embedding_keeps_the_spline() {
let from = Space::polynomial(2, 0.0, 1.0).unwrap();
let ctrl = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 2.0, 0.0), Vec3::new(3.0, -1.0, 1.0)];
let to = Space::with_breaks(3, &[0.0, 0.2, 0.5, 0.7, 1.0]).unwrap();
let out = embed(&from, &ctrl, &to).unwrap();
let spline = Space::with_breaks(3, &[0.0, 0.5, 1.0]).unwrap();
let sctrl: Vec<f64> = (0..spline.count()).map(|i| (i as f64 * 1.7).sin()).collect();
let finer = spline.refined(&[0.25, 0.5, 0.8]).unwrap();
let sout = embed(&spline, &sctrl, &finer).unwrap();
for k in 0..=20 {
let t = k as f64 / 20.0;
assert!(from.eval(&ctrl, t, 0).unwrap().dist(to.eval(&out, t, 0).unwrap()) < 1e-12);
assert!(close(spline.eval(&sctrl, t, 0).unwrap(), finer.eval(&sout, t, 0).unwrap()));
}
assert!(embed(&finer, &sout, &spline).is_err(), "a coarser space");
assert!(embed(&spline, &sctrl, &Space::with_breaks(3, &[0.0, 0.4, 1.0]).unwrap()).is_err(), "a knot missing");
assert!(embed(&spline, &sctrl, &Space::with_breaks(3, &[0.0, 0.5, 2.0]).unwrap()).is_err(), "another domain");
}
#[test]
fn monotone_cubic_is_monotone_and_straight_through_two_points() {
let m = Monotone::new(vec![0.0, 0.3, 0.5, 1.0], vec![0.0, 0.05, 0.9, 1.0]).unwrap();
let mut last = -1.0;
for k in 0..=200 {
let [y, d, _] = m.eval(k as f64 / 200.0);
assert!(y >= last && d >= 0.0, "{k}");
last = y;
}
assert!(close(m.eval(0.3)[0], 0.05) && close(m.eval(0.5)[0], 0.9));
let line = Monotone::new(vec![0.0, 1.0], vec![2.0, 4.0]).unwrap();
assert!(close(line.eval(0.25)[0], 2.5) && close(line.eval(0.7)[1], 2.0) && close(line.eval(0.7)[2], 0.0));
assert!(Monotone::new(vec![0.0, 0.0], vec![0.0, 1.0]).is_err());
assert!(Monotone::new(vec![0.0, 1.0], vec![1.0, 0.0]).is_err());
assert!(Monotone::new(vec![0.0, f64::NAN], vec![0.0, 1.0]).is_err());
}
#[test]
fn hostile_spaces_are_refused() {
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).is_ok());
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]).is_err(), "too few knots");
assert!(Space::new(3, vec![0.0, 0.0, 0.0, 0.1, 1.0, 1.0, 1.0, 1.0]).is_err(), "not clamped");
assert!(Space::new(1, vec![0.0, 0.0, 0.5, 0.5, 1.0, 1.0]).is_err(), "a discontinuous space");
assert!(Space::new(0, vec![0.0, 1.0]).is_err());
assert!(Space::with_breaks(3, &[0.0, 0.5, 0.5, 1.0]).is_err());
assert!(Space::with_breaks(3, &[0.0, f64::NAN, 1.0]).is_err());
let s = Space::with_breaks(3, &[0.0, 1.0]).unwrap();
assert!(s.eval(&[1.0, 2.0], 0.5, 0).is_err(), "wrong control count");
assert!(s.basis(f64::NAN, 0).is_err());
// Repeated conditions do not determine a spline.
assert!(Interpolant::new(s.clone(), vec![Cond { t: 0.5, der: 0 }; 4]).is_err());
assert!(Interpolant::lowest(vec![Cond { t: 2.0, der: 0 }], 0.0, 1.0).is_err());
}
}
+4
View File
@@ -1326,6 +1326,10 @@ impl Body {
/// The reference mesher's angular display mesh: what [`Body::display_mesh_angular`] falls
/// back to when no plan can be made.
pub fn reference_display_mesh_angular(&self, tol: f64, angle: f64) -> Result<Mesh> {
crate::profile::timed(crate::profile::Kind::Tessellate, || self.reference_display_mesh_angular_untimed(tol, angle))
}
fn reference_display_mesh_angular_untimed(&self, tol: f64, angle: f64) -> Result<Mesh> {
if let Some((shared, t)) = self.display_of.as_deref()
&& let Some(placed) =
shared.mesh.get_or_init(|| shared.original.reference_display_mesh_angular(tol, angle).ok()).as_ref().and_then(|m| placed_mesh(m, t))
@@ -501,3 +501,26 @@ fn bodies_apart_joined_keep_their_faces() {
}
assert!(faces_of(h).iter().all(|(change, origins)| *change == Change::Kept && origins.len() == 1), "{}", show(h));
}
/// A plate with a round end and a boss of that radius standing on it (#1209): the boss's wall
/// and the plate's round end are two pieces of one cylinder, one over the other, meeting along a
/// circle at the plate's top. That circle crosses the walls at its level only, so a second boss
/// joined to the plate and a cut through the first, both prism booleans sewn slab by slab, keep
/// every face of the plate and its boss, with their history.
#[test]
fn stacked_pieces_of_one_cylinder_keep_their_walls() {
let plate =
boolean(&boxed(0.0, -6.0, 0.0, 40.0, 6.0, 5.0), &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap(), BoolOp::Union).unwrap().unwrap();
let arm = boolean(&plate, &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 8.0).unwrap(), BoolOp::Union).unwrap().unwrap();
verified(&arm, &[&plate, &crate::cylinder(Vec3::ZERO, Vec3::Z, 10.0, 8.0).unwrap()], 0.01);
// Standing over the plate's far end, as the boss at the arm's other end does.
let second = crate::cylinder(Vec3::new(40.0, 0.0, 0.0), Vec3::Z, 4.0, 7.0).unwrap();
let bore = boxed(-3.0, -3.0, -1.0, 3.0, 3.0, 10.0);
for (tool, op) in [(&second, BoolOp::Union), (&bore, BoolOp::Cut)] {
let r = boolean(&arm, tool, op).unwrap().unwrap();
let h = verified(&r, &[&arm, tool], 0.01);
for f in 0..arm.face_count() {
assert!(!from(h, face(0, f)).is_empty(), "{op:?}: face {f} of the plate and boss is lost\n{}", show(h));
}
}
}
+8 -5
View File
@@ -6,13 +6,16 @@
//! surface — its tangent plane, its curvature, the change of that curvature — as the side's order
//! asks for, with `crown` shaping the middle without touching a boundary condition.
//!
//! A patch through *three or four* boundaries is refused rather than built approximately. At a
//! corner two sides prescribe the same entries of the control net from two different surfaces, and
//! a tensor-product patch can hold both only where those two prescriptions already agree (one wall
//! carrying every side, say). Where they do not — the general case — the requested order would
//! A patch through *three or four* boundaries is refused here rather than built approximately. At
//! a corner two sides prescribe the same entries of the control net from two different surfaces,
//! and a tensor-product patch can hold both only where those two prescriptions already agree (one
//! wall carrying every side, say). Where they do not — the general case — the requested order would
//! silently drop near the corner, or the boundary curve would visibly leave its stations, and a
//! Gregory-type corner (a rational blend of the sides' own data) is needed to hold them instead
//! (issue #763). Everything this module refuses is an error, never a quietly worse surface.
//! (issue #763). A loop of one sketch's curves is the case where they agree: it lies in the
//! sketch's plane, and its flat Coons patch ([`crate::boundary_network`]) holds every order
//! exactly; the network there refuses G1/G2 on two sides that meet at a corner for the same
//! reason. Everything this module refuses is an error, never a quietly worse surface.
use solidcraft_geom::Vec3;
+744
View File
@@ -0,0 +1,744 @@
//! Boundary surfaces (Surface › Boundary): one B-spline face through curves in one or two
//! directions (`solidcraft_geom::network`: a loft between curves, a Coons patch inside four, a
//! Gordon surface through a network), made from sketch curves, 3D sketch curves or a body's
//! edges. A boundary curve that is a chain of open edges of a surface may carry a continuity
//! condition with the face beside it: G1 (tangent) or G2 (curvature).
//!
//! The reusable pieces: [`CurveSource`] turns each kind of curve into a curve the network can
//! measure, [`EdgeFaces`] gives the frame and second fundamental form of the faces along a chain
//! of edges (what a G1 or G2 condition needs), and [`boundary_surface`] builds the face.
//!
//! The two-curve bridge with G0–G3 against a sketch's plane is [`crate::boundary`]; this is the
//! general case: three or four sides, interior curves, 3D curves and edges.
use std::collections::HashSet;
use mt::{BoundedCurve, Cut, InnerSpace, ParametricCurve, ParametricSurface, SearchNearestParameter};
use solidcraft_geom::bspline::{CurveN, NurbsSurface, SplineError};
use solidcraft_geom::network::{self, CrossFace, FaceFrame, NetCurve, NetworkCurve, PointCurve, Tolerances};
use solidcraft_geom::{Plane, Seg2, Vec3};
use truck_modeling as mt;
use crate::body::{Body, from_p3, p3};
use crate::bridge::Continuity;
use crate::plane_fit::EdgeSampler;
use crate::record::Rebuild;
use crate::{KernelError, Result, guard};
/// How far apart curves of the two directions may pass and still cross (mm).
pub const CROSS_TOLERANCE: f64 = 0.01;
/// How far the surface may stray from its curves (mm).
pub const FIT_TOLERANCE: f64 = 1e-4;
/// Most points of a 3D sketch curve a boundary surface follows (longer ones are resampled).
const MAX_WIRE_POINTS: usize = 400;
/// Most points picking the edges of one curve.
pub const MAX_EDGE_PICKS: usize = 10_000;
/// Most sketch curves one curve of a boundary surface chains.
pub const MAX_CHAIN_CURVES: usize = 1000;
/// The network's continuity for a curve's: G3 is refused (a network holds up to G2).
fn net_order(c: &BoundaryCurve) -> Result<network::Continuity> {
match c.continuity {
Continuity::G0 => Ok(network::Continuity::G0),
Continuity::G1 => Ok(network::Continuity::G1),
Continuity::G2 => Ok(network::Continuity::G2),
Continuity::G3 => Err(KernelError::Invalid(format!("not supported yet: G3 continuity on {} (a curve network holds up to G2)", c.label))),
}
}
/// Where a curve of a boundary surface comes from.
pub enum CurveSource<'a> {
/// Sketch segments on a plane, each starting where the one before it ends.
Sketch { plane: Plane, segs: Vec<Seg2> },
/// A 3D sketch curve's points: followed smoothly through them (a cubic spline).
Points(Vec<Vec3>),
/// A chain of a body's edges, each picked by a point on it.
Edges { body: &'a Body, at: Vec<Vec3> },
/// A side collapsed to a point: the apex of a three-sided patch, where the sides beside it
/// meet.
Point(Vec3),
}
/// A curve of a boundary surface: a name for messages, where it comes from, and (for edges of a
/// surface) its continuity with the face beside it.
pub struct BoundaryCurve<'a> {
pub label: String,
pub source: CurveSource<'a>,
pub continuity: Continuity,
}
/// One B-spline face through curves `u` (two or more; or one with a continuity condition, crossed
/// by `v`) and `v` (none: a loft through `u`; two each way and no more: a Coons patch; more: a
/// Gordon surface through the network). A surface body; its history has no operands (the face
/// and its edges are new). Refused, naming the curves: curves that miss each other (by more than
/// [`CROSS_TOLERANCE`]) or cross twice, curves out of order, closed curves, a continuity
/// condition on anything but an open edge of a surface, or on curves of both directions.
pub fn boundary_surface(u: &[BoundaryCurve], v: &[BoundaryCurve]) -> Result<Body> {
for c in u.iter().chain(v) {
if let CurveSource::Edges { body, .. } = &c.source {
body.require_brep("boundary surface")?;
}
}
guard("boundary surface", || {
let mut corners: Vec<Vec3> = Vec::new();
let mut to_net = |c: &BoundaryCurve| -> Result<NetworkCurve<'static>> {
let (curve, face): (Box<dyn NetCurve>, Option<EdgeFaces>) = match &c.source {
CurveSource::Sketch { plane, segs } => (Box::new(SketchChain::new(&c.label, plane, segs)?), None),
CurveSource::Points(pts) => (Box::new(Polyline::new(&c.label, pts)?), None),
CurveSource::Point(p) => {
if !p.is_finite() {
return Err(KernelError::Invalid(format!("{} is not a finite point", c.label)));
}
(Box::new(PointCurve(*p)), None)
}
CurveSource::Edges { body, at } => {
if at.is_empty() || at.len() > MAX_EDGE_PICKS || at.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Invalid(format!("{}: pick 1 to {MAX_EDGE_PICKS} points on its edges", c.label)));
}
let (chain, closed) = crate::surfaces::picked_chain(body, at)?;
if closed {
return Err(KernelError::Invalid(format!("not supported yet: {} is a closed chain of edges", c.label)));
}
// The chain's inner vertices: the face's sides are split there, so that a
// join shares the body's edges one for one.
corners.extend(chain.iter().skip(1).map(|e| from_p3(e.front().point())));
let face = match c.continuity {
Continuity::G0 => None,
_ => Some(EdgeFaces::new(&c.label, body, &chain)?),
};
(Box::new(EdgeChain::new(&c.label, &chain)?), face)
}
};
if c.continuity != Continuity::G0 && face.is_none() {
return Err(KernelError::Invalid(format!("{}: G1 and G2 apply only to a body's edges", c.label)));
}
let order = net_order(c)?;
Ok(NetworkCurve { label: c.label.clone(), curve, condition: face.map(|f| (order, Box::new(f) as Box<dyn CrossFace>)) })
};
let nu: Vec<NetworkCurve> = u.iter().map(&mut to_net).collect::<Result<_>>()?;
let nv: Vec<NetworkCurve> = v.iter().map(&mut to_net).collect::<Result<_>>()?;
let surface = network::network_surface(nu, nv, Tolerances { cross: CROSS_TOLERANCE, fit: FIT_TOLERANCE }).map_err(|e| match e {
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
SplineError::TooLarge(m) => KernelError::TooLarge(m),
SplineError::Invalid(m) => KernelError::Invalid(m),
})?;
let face = face_of(&surface, &corners)?;
let mut rec = Rebuild::default();
rec.made(&face, Vec::new());
crate::surfaces::body_of(vec![face], &[], &rec)
})
}
/// `made` (a boundary surface) sewn into `target` along the edges they share: one body, a solid
/// when it closes the gap. Refused when they share no edge.
pub fn boundary_join(target: &Body, made: &Body) -> Result<Body> {
target.require_brep("join")?;
let edges = |b: &Body| b.solid.edge_iter().map(|e| e.id()).collect::<HashSet<_>>().len();
let joined = crate::surfaces::stitch(&[target, made], CROSS_TOLERANCE)?;
if edges(&joined) == edges(target) + edges(made) {
return Err(KernelError::Invalid("the surface shares no edge with the body it joins".into()));
}
Ok(joined)
}
/// Sketch segments on a plane, run end to end by arc length.
struct SketchChain {
plane: Plane,
segs: Vec<Seg2>,
/// Cumulative length at the start of each segment, and the total.
starts: Vec<f64>,
total: f64,
}
impl SketchChain {
fn new(label: &str, plane: &Plane, segs: &[Seg2]) -> Result<SketchChain> {
if segs.is_empty() || segs.len() > MAX_CHAIN_CURVES {
return Err(KernelError::Invalid(format!("{label} has no curves, or too many")));
}
if segs.windows(2).any(|w| w[0].end().dist(w[1].start()) > 1e-6) {
return Err(KernelError::Invalid(format!("{label}: its curves don't meet end to end")));
}
let mut starts = Vec::with_capacity(segs.len());
let mut total = 0.0;
for s in segs {
starts.push(total);
total += s.length();
}
if !(total > 0.0 && total.is_finite()) {
return Err(KernelError::Invalid(format!("{label} has no length")));
}
Ok(SketchChain { plane: *plane, segs: segs.to_vec(), starts, total })
}
}
impl NetCurve for SketchChain {
fn point(&mut self, t: f64) -> Option<Vec3> {
let at = t.clamp(0.0, 1.0) * self.total;
let k = self.starts.partition_point(|s| *s <= at).saturating_sub(1);
let (seg, start) = (self.segs.get(k)?, self.starts.get(k)?);
let len = seg.length();
let local = if len > 0.0 { ((at - start) / len).clamp(0.0, 1.0) } else { 0.0 };
let p = self.plane.to_world(seg.point_at(local));
p.is_finite().then_some(p)
}
}
/// A 3D polyline followed smoothly: a cubic through its points by chord length (fewer points, a
/// lower degree; a long one resampled first).
struct Polyline {
curve: CurveN<3>,
end: f64,
}
impl Polyline {
fn new(label: &str, pts: &[Vec3]) -> Result<Polyline> {
let mut clean: Vec<Vec3> = Vec::with_capacity(pts.len());
for p in pts {
if !p.is_finite() {
return Err(KernelError::Invalid(format!("{label} has a point that is not a finite number")));
}
if clean.last().is_none_or(|q: &Vec3| q.dist(*p) > 1e-9) {
clean.push(*p);
}
}
let pts = resample(&clean, MAX_WIRE_POINTS);
if pts.len() < 2 {
return Err(KernelError::Invalid(format!("{label} has no length")));
}
let mut params = vec![0.0];
for w in pts.windows(2) {
params.push(params.last().copied().unwrap_or(0.0) + w[0].dist(w[1]));
}
let end = params.last().copied().unwrap_or(0.0);
let data: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
let curve = CurveN::interpolate(&params, &data, (pts.len() - 1).min(3))
.map_err(|e| KernelError::Invalid(format!("{label} can't be followed smoothly: {e}")))?;
Ok(Polyline { curve, end })
}
}
impl NetCurve for Polyline {
fn point(&mut self, t: f64) -> Option<Vec3> {
let ([x, y, z], _) = self.curve.eval(t.clamp(0.0, 1.0) * self.end)?;
Some(Vec3::new(x, y, z))
}
}
/// At most `max` points evenly along a polyline by arc length, its ends kept.
fn resample(pts: &[Vec3], max: usize) -> Vec<Vec3> {
if pts.len() <= max || max < 2 {
return pts.to_vec();
}
let mut cum = vec![0.0];
for w in pts.windows(2) {
cum.push(cum.last().copied().unwrap_or(0.0) + w[0].dist(w[1]));
}
let total = cum.last().copied().unwrap_or(0.0);
(0..max)
.filter_map(|k| {
let at = total * k as f64 / (max - 1) as f64;
let i = cum.partition_point(|c| *c <= at).saturating_sub(1).min(pts.len().saturating_sub(2));
let (c0, c1) = (cum.get(i)?, cum.get(i + 1)?);
let w = if c1 > c0 { ((at - c0) / (c1 - c0)).clamp(0.0, 1.0) } else { 0.0 };
Some(pts.get(i)?.lerp(*pts.get(i + 1)?, w))
})
.collect()
}
/// A chain of edges (each turned to run along it), by their lengths.
struct EdgeChain {
edges: Vec<EdgeSampler>,
starts: Vec<f64>,
total: f64,
}
impl EdgeChain {
fn new(label: &str, chain: &[mt::Edge]) -> Result<EdgeChain> {
let mut edges = Vec::with_capacity(chain.len());
let mut starts = Vec::with_capacity(chain.len());
let mut total = 0.0;
for e in chain {
let mut s = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge has no parameter range")))?;
let mut len = 0.0;
let mut last: Option<Vec3> = None;
for k in 0..=16 {
let p = s.at(k as f64 / 16.0).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge can't be evaluated")))?;
if let Some(q) = last {
len += p.dist(q);
}
last = Some(p);
}
starts.push(total);
total += len;
edges.push(s);
}
if !(total > 0.0 && total.is_finite()) {
return Err(KernelError::Invalid(format!("{label} has no length")));
}
Ok(EdgeChain { edges, starts, total })
}
}
impl NetCurve for EdgeChain {
fn point(&mut self, t: f64) -> Option<Vec3> {
let at = t.clamp(0.0, 1.0) * self.total;
let k = self.starts.partition_point(|s| *s <= at).saturating_sub(1);
let start = *self.starts.get(k)?;
let end = self.starts.get(k + 1).copied().unwrap_or(self.total);
let local = if end > start { ((at - start) / (end - start)).clamp(0.0, 1.0) } else { 0.0 };
self.edges.get_mut(k)?.at(local)
}
}
/// The faces along a chain of edges, each edge an open edge of one face of its body: their
/// frames and second fundamental forms where a boundary surface meets them (G1 and G2
/// conditions).
pub(crate) struct EdgeFaces {
sides: Vec<EdgeSide>,
}
/// One edge of the chain: points along it (to find the edge nearest a point), the surface of
/// its face, whether the face's boundary runs along the chain (`along`), and the surface
/// parameters found last (the next search starts there).
struct EdgeSide {
pts: Vec<Vec3>,
surface: mt::Surface,
along: bool,
hint: Option<(f64, f64)>,
}
impl EdgeFaces {
pub(crate) fn new(label: &str, body: &Body, chain: &[mt::Edge]) -> Result<EdgeFaces> {
let mut sides = Vec::with_capacity(chain.len());
for e in chain {
let mut faces = body.solid.face_iter().filter(|f| f.boundaries().iter().flatten().any(|x| x.id() == e.id()));
let (Some(face), None) = (faces.next(), faces.next()) else {
return Err(KernelError::Invalid(format!(
"{label}: G1 and G2 need an open edge of a surface (an edge with a face on one side only)"
)));
};
// How the face's own (absolute) boundary runs this edge.
let in_wire = face.absolute_boundaries().iter().flatten().find(|x| x.id() == e.id()).cloned();
let in_wire = in_wire.ok_or_else(|| KernelError::Failed("boundary surface: an edge's face".into()))?;
let along = in_wire.front().id() == e.front().id();
let mut sampler = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid(format!("{label}: an edge has no parameter range")))?;
let pts = (0..=16).map(|k| sampler.at(k as f64 / 16.0)).collect::<Option<Vec<_>>>();
let pts = pts.ok_or_else(|| KernelError::Invalid(format!("{label}: an edge can't be evaluated")))?;
sides.push(EdgeSide { pts, surface: face.surface(), along, hint: None });
}
Ok(EdgeFaces { sides })
}
}
impl CrossFace for EdgeFaces {
fn frame(&mut self, at: Vec3, tangent: Vec3) -> Option<FaceFrame> {
// The edge nearest the point, and the chain's direction along it there.
let near = |s: &EdgeSide| s.pts.windows(2).map(|w| at.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
let k = (0..self.sides.len()).min_by(|a, b| near(&self.sides[*a]).total_cmp(&near(&self.sides[*b])))?;
let side = self.sides.get_mut(k)?;
let seg = side.pts.windows(2).min_by(|a, b| at.dist_to_segment(a[0], a[1]).total_cmp(&at.dist_to_segment(b[0], b[1])))?;
let chain_dir = seg[1] - seg[0];
let s = &side.surface;
let (u, v) = s.search_nearest_parameter(p3(at), side.hint, 100).or_else(|| s.search_nearest_parameter(p3(at), None, 100))?;
side.hint = Some((u, v));
let (su, sv) = (s.uder(u, v), s.vder(u, v));
let n = su.cross(sv);
if !(n.magnitude2() > 0.0) {
return None;
}
let n = n.normalize();
let normal = Vec3::new(n.x, n.y, n.z);
// The face's boundary runs counter-clockwise about its surface's normal, so the face lies
// to the left of it: outward is the boundary's direction × the normal.
let wire = if side.along { chain_dir } else { -chain_dir };
let outward = wire.cross(normal).normalized()?;
// The second fundamental form, in the basis of the tangent asked about and outward.
let t = (tangent - normal * tangent.dot(normal)).normalized()?;
let b = (outward - t * outward.dot(t)).normalized()?;
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
let det = e * g - f * f;
if !(det.abs() > 1e-24) {
return None;
}
// II(w, w) = L·α² + 2M·αβ + N·β² for w = α·S_u + β·S_v, with L, M, N the surface's
// second derivatives along its unit normal (do Carmo, §3-3): exact, rational surfaces
// included, and with nothing sampled beyond the edge, where the face's domain ends.
let nv = mt::Vector3::new(normal.x, normal.y, normal.z);
let (l, m, nn) = (s.uuder(u, v).dot(nv), s.uvder(u, v).dot(nv), s.vvder(u, v).dot(nv));
let ii = |w: Vec3| -> Option<f64> {
let wv = mt::Vector3::new(w.x, w.y, w.z);
let (p, q) = (wv.dot(su), wv.dot(sv));
let (alpha, beta) = ((p * g - q * f) / det, (q * e - p * f) / det);
let out = l * alpha * alpha + 2.0 * m * alpha * beta + nn * beta * beta;
out.is_finite().then_some(out)
};
let (tt, bb) = (ii(t)?, ii(b)?);
let tb = (ii(t + b)? - tt - bb) / 2.0;
let form = [tt, tb, bb];
form.iter().all(|x| x.is_finite()).then_some(FaceFrame { normal, outward: b, form })
}
}
/// The face of a network surface: its four sides from the surface's boundary rows, each split
/// at the `corners` that lie on it (inner vertices of edge chains). Refused when a side shrinks to
/// a point.
fn face_of(s: &NurbsSurface, corners: &[Vec3]) -> Result<mt::Face> {
let fail = |m: &str| KernelError::Failed(format!("boundary surface: {m}"));
let [nu, nv] = s.count();
let [ku, kv] = s.knots();
let rows: Vec<Vec<mt::Point3>> = (0..nu).map(|i| (0..nv).map(|j| p3(s.point(i, j).xyz())).collect()).collect();
let (uk, vk) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
let surface = mt::BSplineSurface::try_new((uk.clone(), vk.clone()), rows.clone()).map_err(|e| fail(&e.to_string()))?;
let row = |pts: Vec<mt::Point3>, k: &mt::KnotVec| mt::BSplineCurve::try_new(k.clone(), pts).map_err(|e| fail(&e.to_string()));
let (Some(first), Some(last)) = (rows.first(), rows.last()) else { return Err(fail("control net")) };
let bottom = row(rows.iter().filter_map(|r| r.first().copied()).collect(), &uk)?;
let top = row(rows.iter().filter_map(|r| r.last().copied()).collect(), &uk)?;
let left = row(first.clone(), &vk)?;
let right = row(last.clone(), &vk)?;
let corner = |c: &mt::BSplineCurve<mt::Point3>, end: bool| {
let (t0, t1) = c.range_tuple();
c.subs(if end { t1 } else { t0 })
};
let (c00, c10, c11, c01) = (corner(&bottom, false), corner(&bottom, true), corner(&top, true), corner(&top, false));
let size = [c00, c10, c11, c01].iter().map(|p| from_p3(*p).dist(from_p3(c00))).fold(0.0, f64::max).max(1.0);
// A side collapsed to a point (a three-sided patch) is left out of the loop: the two sides
// beside it meet at one vertex there.
let sides = [(c00, c10), (c10, c11), (c01, c11), (c00, c01)];
let collapsed: Vec<bool> = sides.iter().map(|(a, b)| from_p3(*a).dist(from_p3(*b)) <= size * 1e-9).collect();
match collapsed.iter().filter(|c| **c).count() {
0 | 1 => {}
_ => return Err(KernelError::Invalid("not supported yet: a boundary surface with more than one side shrunk to a point".into())),
}
let mut verts: Vec<mt::Vertex> = Vec::with_capacity(4);
for c in [c00, c10, c11, c01] {
let same = verts.iter().find(|v| from_p3(v.point()).dist(from_p3(c)) <= size * 1e-9).cloned();
verts.push(same.unwrap_or_else(|| mt::Vertex::new(c)));
}
let (Some(v00), Some(v10), Some(v11), Some(v01)) = (verts.first(), verts.get(1), verts.get(2), verts.get(3)) else {
return Err(fail("corners"));
};
let is = |k: usize| collapsed.get(k).copied().unwrap_or(false);
let mut wire: Vec<mt::Edge> = Vec::new();
if !is(0) {
wire.extend(side_edges(bottom, v00, v10, corners));
}
if !is(1) {
wire.extend(side_edges(right, v10, v11, corners));
}
if !is(2) {
wire.extend(side_edges(top, v01, v11, corners).into_iter().rev().map(|e| e.inverse()));
}
if !is(3) {
wire.extend(side_edges(left, v00, v01, corners).into_iter().rev().map(|e| e.inverse()));
}
// u along the bottom, v up the left: counter-clockwise in (u, v).
mt::Face::try_new(vec![wire.into()], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))
}
/// The edges of one side from `a` to `b`, split at the points of `corners` that lie on it
/// (within [`CROSS_TOLERANCE`]) strictly between its ends.
fn side_edges(mut curve: mt::BSplineCurve<mt::Point3>, a: &mt::Vertex, b: &mt::Vertex, corners: &[Vec3]) -> Vec<mt::Edge> {
let (t0, t1) = curve.range_tuple();
let margin = (t1 - t0) * 1e-6;
let mut cuts: Vec<f64> = corners
.iter()
.filter_map(|c| {
let t = curve.search_nearest_parameter(p3(*c), None, 100)?;
(t > t0 + margin && t < t1 - margin && from_p3(curve.subs(t)).dist(*c) <= CROSS_TOLERANCE).then_some(t)
})
.collect();
cuts.sort_by(f64::total_cmp);
cuts.dedup_by(|x, y| (*x - *y).abs() <= margin);
let mut edges = Vec::with_capacity(cuts.len() + 1);
let mut from = a.clone();
// Cut from the far end back, so the earlier parameters stay on the part kept.
let mut tails = Vec::with_capacity(cuts.len());
for &t in cuts.iter().rev() {
tails.push(curve.cut(t));
}
let mut pieces = vec![curve];
pieces.extend(tails.into_iter().rev());
let count = pieces.len();
for (k, piece) in pieces.into_iter().enumerate() {
let to = if k + 1 == count { b.clone() } else { mt::Vertex::new(piece.subs(piece.range_tuple().1)) };
edges.push(mt::Edge::new(&from, &to, mt::Curve::BSplineCurve(piece)));
from = to;
}
edges
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patch_region;
use mt::ParametricSurface3D;
use solidcraft_geom::{Loop2, Region2, Vec2};
fn sketch(label: &str, plane: Plane, segs: Vec<Seg2>) -> BoundaryCurve<'static> {
BoundaryCurve { label: label.into(), source: CurveSource::Sketch { plane, segs }, continuity: Continuity::G0 }
}
fn line3(label: &str, a: Vec3, b: Vec3) -> BoundaryCurve<'static> {
BoundaryCurve { label: label.into(), source: CurveSource::Points(vec![a, b]), continuity: Continuity::G0 }
}
fn face_surface(b: &Body) -> mt::Surface {
b.solid.face_iter().next().unwrap().surface()
}
/// The flat sheet y ∈ [−10, 0], x ∈ [0, 10] in z = 0, its edge along y = 0 open.
fn sheet() -> Body {
let sq = Loop2::polygon(&[Vec2::new(0.0, -10.0), Vec2::new(10.0, -10.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 0.0)]);
patch_region(&Plane::XY, &Region2 { outer: sq, holes: vec![] }).unwrap()
}
/// A Coons patch of four lines bounding the hyperbolic paraboloid z = x·y/10: every point of
/// the face lies on it, and its area is the paraboloid's over the square.
#[test]
fn coons_patch_of_lines_is_a_hyperbolic_paraboloid() {
let p = |x: f64, y: f64| Vec3::new(x, y, x * y / 10.0);
let body = boundary_surface(
&[line3("u0", p(0.0, 0.0), p(10.0, 0.0)), line3("u1", p(0.0, 10.0), p(10.0, 10.0))],
&[line3("v0", p(0.0, 0.0), p(0.0, 10.0)), line3("v1", p(10.0, 0.0), p(10.0, 10.0))],
)
.unwrap();
assert!(body.is_surface() && body.face_count() == 1);
let s = face_surface(&body);
for i in 0..=10 {
for j in 0..=10 {
let q = from_p3(s.subs(i as f64 / 10.0, j as f64 / 10.0));
assert!((q.z - q.x * q.y / 10.0).abs() < 1e-9, "{q:?}");
}
}
}
/// Sketch curves: a patch inside two arcs and two lines on one plane lies in that plane.
#[test]
fn coplanar_sketch_curves_give_a_plane() {
use std::f64::consts::PI;
// Two half-discs' arcs over [0, 10] (radius 5) facing out, joined by the lines x = 0, 10.
let low = Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 5.0, start: PI, sweep: PI };
let high = Seg2::Arc { center: Vec2::new(5.0, 8.0), radius: 5.0, start: PI, sweep: -PI };
let left = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 8.0) };
let right = Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 8.0) };
let pl = Plane::XY.offset(3.0);
let body = boundary_surface(
&[sketch("low", pl, vec![low]), sketch("high", pl, vec![high])],
&[sketch("left", pl, vec![left]), sketch("right", pl, vec![right])],
)
.unwrap();
let s = face_surface(&body);
for i in 0..=8 {
for j in 0..=8 {
assert!((s.subs(i as f64 / 8.0, j as f64 / 8.0).z - 3.0).abs() < 1e-9);
}
}
}
/// A stadium: two half-circle arcs (radius 5) bulging out of the 10 × 8 rectangle between two
/// lines. Each arc meets its lines at 180°, so the patch is singular at its corners, and the
/// reference mesher's walk round the boundary used to run from such a corner onto the
/// surface's extrapolation past its domain (a planar spline passes through every point of its
/// plane there): the face measured 289 mm² for its 158.5. The vendored mesher's bounded
/// projection (`nearest_projection`, vendor/README.md) keeps the walk on the domain. Its area
/// is the stadium's, 80 + 25π, by the faces, the measuring mesh and the display mesh.
#[test]
fn a_patch_with_straight_corners_measures_its_own_area() {
use std::f64::consts::PI;
let low = Seg2::Arc { center: Vec2::new(5.0, 0.0), radius: 5.0, start: PI, sweep: PI };
let high = Seg2::Arc { center: Vec2::new(5.0, 8.0), radius: 5.0, start: PI, sweep: -PI };
let left = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(0.0, 8.0) };
let right = Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 8.0) };
let pl = Plane::XY.offset(3.0);
let body = boundary_surface(
&[sketch("low", pl, vec![low]), sketch("high", pl, vec![high])],
&[sketch("left", pl, vec![left]), sketch("right", pl, vec![right])],
)
.unwrap();
let want = 80.0 + 25.0 * PI;
// Chords of the arcs at 0.01 mm sag cut off up to (2/3)·sag·length ≈ 0.21 mm².
let near = |a: f64| (a - want).abs() < 0.25;
let faces = body.faces(0.01).unwrap();
assert!(faces.len() == 1 && near(faces[0].area), "faces: {faces:?}");
let tess = body.tessellate(0.01).unwrap().measure().area;
assert!(near(tess), "measuring mesh: {tess}");
let display = body.display_mesh(0.01).unwrap().measure().area;
assert!(near(display), "display mesh: {display}");
}
/// Three sides: a quarter disc of radius 10 (two radii and the arc) is one face with three
/// edges, the collapsed side left out of its loop, and measures 25π by its faces and both
/// meshes.
#[test]
fn a_three_sided_patch_is_one_face_with_three_edges() {
use std::f64::consts::{FRAC_PI_2, PI};
let r = 10.0;
let arc = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: r, start: 0.0, sweep: FRAC_PI_2 };
let x = Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(r, 0.0) };
let y = Seg2::Line { a: Vec2::new(0.0, r), b: Vec2::new(0.0, 0.0) };
let pl = Plane::XY;
let apex = BoundaryCurve { label: "centre".into(), source: CurveSource::Point(Vec3::ZERO), continuity: Continuity::G0 };
let body = boundary_surface(&[sketch("arc", pl, vec![arc]), apex], &[sketch("x", pl, vec![x]), sketch("y", pl, vec![y])]).unwrap();
assert!(body.is_surface() && body.face_count() == 1);
let edges: std::collections::HashSet<_> = body.solid.edge_iter().map(|e| e.id()).collect();
assert_eq!(edges.len(), 3, "the collapsed side is no edge");
let want = PI * r * r / 4.0;
let near = |a: f64| (a - want).abs() < 0.2;
let faces = body.faces(0.01).unwrap();
assert!(near(faces[0].area), "faces: {faces:?}");
let tess = body.tessellate(0.01).unwrap().measure().area;
assert!(near(tess), "measuring mesh: {tess}");
let display = body.display_mesh(0.01).unwrap().measure().area;
assert!(near(display), "display mesh: {display}");
// Two collapsed sides are no patch.
let p = |l: &str, q: Vec3| BoundaryCurve { label: l.into(), source: CurveSource::Point(q), continuity: Continuity::G0 };
assert!(
boundary_surface(&[sketch("arc", pl, vec![arc]), p("o", Vec3::ZERO)], &[p("a", Vec3::new(r, 0.0, 0.0)), sketch("y", pl, vec![y])])
.is_err()
);
assert!(boundary_surface(&[sketch("arc", pl, vec![arc]), p("nan", Vec3::new(f64::NAN, 0.0, 0.0))], &[]).is_err());
}
/// The angle between the two faces' normals across the edge at `p`.
fn normal_angle(a: &mt::Surface, b: &mt::Surface, p: Vec3) -> f64 {
let n = |s: &mt::Surface| {
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).unwrap();
let n = s.normal(u, v);
Vec3::new(n.x, n.y, n.z)
};
n(a).dot(n(b)).abs().clamp(0.0, 1.0).acos().to_degrees()
}
/// G1 against a flat sheet's open edge: a loft from the edge to a line above, and a patch
/// whose sides are arcs leaving the sheet tangent to it. The normals across the shared edge
/// agree within 0.1°; the patch joins the sheet into one surface body.
#[test]
fn g1_meets_the_sheet_tangent() {
let base = sheet();
let edge =
|g| BoundaryCurve { label: "edge".into(), source: CurveSource::Edges { body: &base, at: vec![Vec3::new(5.0, 0.0, 0.0)] }, continuity: g };
let far = || line3("far", Vec3::new(0.0, 10.0, 5.0), Vec3::new(10.0, 10.0, 5.0));
let loft = boundary_surface(&[edge(Continuity::G1), far()], &[]).unwrap();
let plain = boundary_surface(&[edge(Continuity::G0), far()], &[]).unwrap();
let (sb, sl, sp) = (face_surface(&base), face_surface(&loft), face_surface(&plain));
for k in 0..=10 {
let p = Vec3::new(k as f64, 0.0, 0.0);
let a = normal_angle(&sb, &sl, p);
assert!(a < 0.1, "G1 at x = {k}: {a}°");
}
assert!(normal_angle(&sb, &sp, Vec3::new(5.0, 0.0, 0.0)) > 10.0, "G0 has a crease");
// Arcs in x = 0 and x = 10 from the edge, tangent to the sheet, up to the far line.
let arc_plane = |x: f64| Plane::new(Vec3::new(x, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
let arc = Seg2::Arc { center: Vec2::new(0.0, 12.5), radius: 12.5, start: -std::f64::consts::FRAC_PI_2, sweep: 0.927_295_218_001_612_2 };
let patch = boundary_surface(
&[edge(Continuity::G1), far()],
&[sketch("left", arc_plane(0.0), vec![arc]), sketch("right", arc_plane(10.0), vec![arc])],
)
.unwrap();
let spa = face_surface(&patch);
for k in 0..=10 {
let a = normal_angle(&sb, &spa, Vec3::new(k as f64, 0.0, 0.0));
assert!(a < 0.1, "G1 patch at x = {k}: {a}°");
}
let joined = boundary_join(&base, &patch).unwrap();
assert_eq!(joined.face_count(), 2);
assert!(
boundary_join(&base, &boundary_surface(&[far(), line3("other", Vec3::new(0.0, 20.0, 5.0), Vec3::new(10.0, 20.0, 5.0))], &[]).unwrap())
.is_err()
);
}
/// Normal curvature of `s` at the point nearest `p` in the direction `w` (projected into the
/// tangent plane), signed against the unit normal `n`.
fn normal_curvature(s: &mt::Surface, p: Vec3, w: Vec3, n: Vec3) -> f64 {
let (u, v) = s.search_nearest_parameter(p3(p), None, 100).unwrap();
let v3 = |x: mt::Vector3| Vec3::new(x.x, x.y, x.z);
let (su, sv) = (v3(s.uder(u, v)), v3(s.vder(u, v)));
let (suu, suv, svv) = (v3(s.uuder(u, v)), v3(s.uvder(u, v)), v3(s.vvder(u, v)));
let (e, f, g) = (su.dot(su), su.dot(sv), sv.dot(sv));
let (l, m, nn) = (suu.dot(n), suv.dot(n), svv.dot(n));
let (p1, q1) = (w.dot(su), w.dot(sv));
let det = e * g - f * f;
let (a, b) = ((p1 * g - q1 * f) / det, (q1 * e - p1 * f) / det);
(l * a * a + 2.0 * m * a * b + nn * b * b) / (e * a * a + 2.0 * f * a * b + g * b * b)
}
/// G2 against a real cylinder's open edge (R = 10, the face below the edge): across the edge
/// the surface's normal curvature is the cylinder's, −1/R about its outward normal, within
/// 0.1 % — also when the far curve is sheared along the edge, so that the surface's own
/// cross direction leans. With G1 alone it is not.
#[test]
fn g2_matches_a_cylinder_across_its_edge() {
let r = 10.0;
let pl = Plane::new(Vec3::ZERO, Vec3::Y, Vec3::Z).unwrap();
let arc = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: r, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::FRAC_PI_2 };
let cyl = crate::extrude_sheet(&pl, &[vec![arc]], 0.0, 10.0).unwrap();
let sc = face_surface(&cyl);
let made = |g, shear: f64| {
let edge =
BoundaryCurve { label: "rim".into(), source: CurveSource::Edges { body: &cyl, at: vec![Vec3::new(5.0, r, 0.0)] }, continuity: g };
let top = line3("top", Vec3::new(shear, 3.0, 12.0), Vec3::new(10.0 + shear, 3.0, 12.0));
face_surface(&boundary_surface(&[edge, top], &[]).unwrap())
};
for shear in [0.0, 5.0] {
let sm = made(Continuity::G2, shear);
for k in 1..=9 {
let p = Vec3::new(k as f64, r, 0.0);
let kc = normal_curvature(&sc, p, Vec3::Z, Vec3::Y);
assert!((kc + 1.0 / r).abs() < 1e-9, "the cylinder's own curvature: {kc}");
let km = normal_curvature(&sm, p, Vec3::Z, Vec3::Y);
assert!((km - kc).abs() < 1e-3 / r, "shear {shear}, x = {k}: {km} across the edge where the cylinder has {kc}");
// Along the edge both are straight.
assert!(normal_curvature(&sm, p, Vec3::X, Vec3::Y).abs() < 1e-6);
assert!(normal_angle(&sc, &sm, p) < 0.1);
}
}
let g1 = made(Continuity::G1, 0.0);
let k1 = normal_curvature(&g1, Vec3::new(5.0, r, 0.0), Vec3::Z, Vec3::Y);
assert!((k1 + 1.0 / r).abs() > 0.1 / r, "G1 alone does not carry the curvature: {k1}");
}
/// G3 is more than a network holds: refused, never a panic.
#[test]
fn g3_is_not_supported() {
let base = sheet();
let edge = BoundaryCurve {
label: "edge".into(),
source: CurveSource::Edges { body: &base, at: vec![Vec3::new(5.0, 0.0, 0.0)] },
continuity: Continuity::G3,
};
let e = boundary_surface(&[edge, line3("far", Vec3::new(0.0, 10.0, 5.0), Vec3::new(10.0, 10.0, 5.0))], &[]).unwrap_err();
assert!(e.to_string().contains("not supported yet"), "{e}");
}
/// Refusals: G1 on a sketch curve, on an edge between two faces, curves that miss.
#[test]
fn bad_boundaries_are_refused() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let edge = BoundaryCurve {
label: "edge".into(),
source: CurveSource::Edges { body: &cube, at: vec![Vec3::new(5.0, 0.0, 0.0)] },
continuity: Continuity::G1,
};
let far = line3("far", Vec3::new(0.0, 10.0, 15.0), Vec3::new(10.0, 10.0, 15.0));
let e = boundary_surface(&[edge, far], &[]).unwrap_err();
assert!(e.to_string().contains("open edge"), "{e}");
let mut tangent = line3("t", Vec3::ZERO, Vec3::X);
tangent.continuity = Continuity::G1;
assert!(boundary_surface(&[tangent, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
let miss = boundary_surface(
&[line3("a", Vec3::ZERO, Vec3::X * 10.0), line3("b", Vec3::Y * 10.0, Vec3::new(10.0, 10.0, 0.0))],
&[line3("c", Vec3::ZERO, Vec3::Y * 10.0), line3("d", Vec3::new(10.0, 0.0, 2.0), Vec3::new(10.0, 10.0, 2.0))],
)
.unwrap_err();
assert!(miss.to_string().contains("don't meet"), "{miss}");
let pts = BoundaryCurve { label: "p".into(), source: CurveSource::Points(vec![Vec3::ZERO; 3]), continuity: Continuity::G0 };
assert!(boundary_surface(&[pts, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
let nan = BoundaryCurve {
label: "n".into(),
source: CurveSource::Points(vec![Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]),
continuity: Continuity::G0,
};
assert!(boundary_surface(&[nan, line3("b", Vec3::Y, Vec3::new(1.0, 1.0, 0.0))], &[]).is_err());
}
}
+35 -2
View File
@@ -190,6 +190,19 @@ pub(crate) fn frame_ok(plane: &Plane) -> bool {
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
extrude_with(plane, regions, start, end, false)
}
/// [`extrude`] with each run of profile lines that continue one another in a straight line (a side
/// drawn in two pieces, a mirrored half meeting its mirror image) swept into one side face over
/// all its pieces, rather than coplanar faces with an edge between them (#1788). The side's
/// history lists every piece under it. For the Extrude feature; other builders (sheet metal's
/// flat pattern, tools) keep a face per segment.
pub fn extrude_whole_sides(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
extrude_with(plane, regions, start, end, true)
}
fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_sides: bool) -> Result<Vec<Body>> {
if !frame_ok(plane) {
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
}
@@ -206,7 +219,19 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
for r in regions {
region_ok(r)?;
let base = plane.offset(lo);
let profile = region_wires(&base, &oriented(r)?)?;
let pieces = oriented(r)?;
let numbered = if whole_sides { pieces.map_loops(|l| l.merged_lines(continues_straight)) } else { pieces.clone() };
// A joined side's source is its first piece, a segment of the profile as drawn (the side
// lies along it): the sketch curve it is certified and named by, as when it stood alone.
let first_piece = |side: &ProfileEdge| {
pieces
.loops()
.flat_map(|l| l.lp.segs.iter())
.copied()
.find(|piece| side.numbers.len() > 1 && piece.start() == side.segment.start())
.unwrap_or(side.segment)
};
let profile = region_wires(&base, &numbered)?;
let wires = wires_of(&profile);
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
@@ -263,7 +288,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
_ => None,
};
(
Some(crate::FaceSource::ExtrudeSide { segment: side.segment, plane: basis, coalesced: false }),
Some(crate::FaceSource::ExtrudeSide { segment: first_piece(side), plane: basis, coalesced: false }),
Some(record::profile(&side.numbers)),
)
} else if Some(*id) == end {
@@ -285,6 +310,14 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
Ok(out)
}
/// `t` carries on from `s` in the same straight line: the turn at their meeting point is within
/// rounding of none, measured against both lengths.
fn continues_straight(s: &Seg2, t: &Seg2) -> bool {
let (u, v) = (s.end() - s.start(), t.end() - t.start());
let (lu, lv) = (u.len(), v.len());
lu > 0.0 && lv > 0.0 && s.end().dist(t.start()) <= 1e-9 * (lu + lv) && u.dot(v) > 0.0 && u.cross(v).abs() <= 1e-9 * lu * lv
}
/// Choose the per-profile axis sign whose positive rotation follows the sketch normal.
pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result<Vec3> {
if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() {
+33
View File
@@ -244,3 +244,36 @@ fn face_sources_follow_kept_split_and_agreeing_merged_faces() {
let s = h.face_sources(&[&a]);
assert_eq!(s, vec![src(0), src(1), None, src(2), None]);
}
/// A side drawn in two collinear pieces (here the bottom edge, split at x = 10) sweeps into one
/// side face over both pieces, not two coplanar faces with an edge between them (#1788).
#[test]
fn collinear_profile_lines_extrude_into_one_side_face() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let pts = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
// Plain `extrude` keeps a face per segment (sheet metal's flat pattern relies on it).
assert_eq!(crate::extrude(&Plane::XY, std::slice::from_ref(&region), 0.0, 5.0).unwrap()[0].face_count(), 7);
let body = crate::extrude_whole_sides(&Plane::XY, &[region], 0.0, 5.0).unwrap().pop().unwrap();
assert_eq!(body.face_count(), 6, "a box has six faces");
assert!((crate::measure(&body).unwrap().volume - 1000.0).abs() < 1e-9);
let h = body.history().unwrap();
h.check(&body, &[]).unwrap();
let mut sides: Vec<Vec<FaceOrigin>> =
h.faces.iter().map(|f| f.origins.clone()).filter(|o| matches!(o.first(), Some(FaceOrigin::Profile { .. }))).collect();
sides.sort();
assert_eq!(
sides,
vec![
vec![FaceOrigin::Profile { segment: 0 }, FaceOrigin::Profile { segment: 1 }],
vec![FaceOrigin::Profile { segment: 2 }],
vec![FaceOrigin::Profile { segment: 3 }],
vec![FaceOrigin::Profile { segment: 4 }],
],
"the bottom side is generated by both its pieces"
);
// A corner is not a straight run: an L-shaped step keeps its faces.
let step = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (20.0, 5.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
let body = crate::extrude_whole_sides(&Plane::XY, &[Region2 { outer: Loop2::polygon(&step), holes: vec![] }], 0.0, 5.0).unwrap().pop().unwrap();
assert_eq!(body.face_count(), 8);
}
+6 -2
View File
@@ -25,6 +25,7 @@ mod boolean_guard;
mod boolean_record;
mod bore_guard;
mod boundary;
mod boundary_network;
mod bridge;
mod build;
mod clearance;
@@ -121,6 +122,8 @@ mod surface_extend;
mod surface_kind;
mod surface_offset;
mod surface_ruled;
#[cfg(test)]
mod surface_second_derivative_tests;
mod surface_untrim;
mod surfaces;
mod sweep_path;
@@ -145,10 +148,11 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use boundary::{Boundary, boundary_patch};
pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface};
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
pub use build::{
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve,
revolve_whole, sphere, sweep, sweep_with, torus,
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, extrude_whole_sides, loft,
path_polyline, revolve, revolve_whole, sphere, sweep, sweep_with, torus,
};
pub use clearance::{Clearance, Occupancy};
pub use coaxial_overlap::{CoaxialOverlap, coaxial_overlap};
+4
View File
@@ -343,6 +343,10 @@ fn faceted_polys(b: &Body, tol: f64, operand: usize) -> Option<Vec<Poly>> {
/// triangles of `b`; an edge between facets of one face of `a`, or between triangles of `b`,
/// has no origin.
pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
crate::profile::timed(crate::profile::Kind::Boolean, || faceted_boolean_untimed(a, b, op, tol))
}
fn faceted_boolean_untimed(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
if !(tol.is_finite() && tol > 0.0) {
return Err(KernelError::Invalid("facet tolerance".into()));
+42 -3
View File
@@ -435,6 +435,9 @@ pub(crate) struct Index {
points: Vec<Vec3>,
/// Per edge (edge order): whether it is a line.
lines: Vec<bool>,
/// Per edge (edge order) that is not a line: the points its curve lies within (see
/// [`control_hull`]); `None` where they are not known.
hulls: Vec<Option<Vec<Vec3>>>,
}
impl Index {
@@ -451,6 +454,7 @@ impl Index {
}
}
let lines = edges.iter().map(|e| matches!(e.curve(), mt::Curve::Line(_))).collect();
let hulls = edges.iter().map(|e| control_hull(&e.curve())).collect();
let mut between: std::collections::HashMap<(usize, usize), Vec<usize>> = std::collections::HashMap::new();
for (i, fs) in bounds.iter().enumerate() {
match fs.as_slice() {
@@ -464,7 +468,7 @@ impl Index {
.zip(bounds)
.map(|(e, fs)| (fs, vertices.get(&e.absolute_front().id()).copied(), vertices.get(&e.absolute_back().id()).copied()))
.collect();
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines }
Index { operand, edges: crate::record::edge_index(&b.solid), bounds, between, vertices, points, lines, hulls }
}
/// The tag of the segments read off edge `e` (as it runs in a wire) of cap face `cap`.
@@ -517,7 +521,7 @@ struct Stations {
/// Each wall's highest level.
tops: std::collections::HashMap<usize, f64>,
/// Per wall, the edges across it: the other wall and the edge's lowest and highest levels.
/// A curve other than a line spans every level.
/// A curve other than a line spans every level, unless it lies at one level.
cross: std::collections::HashMap<usize, Vec<(usize, f64, f64)>>,
}
@@ -540,7 +544,13 @@ impl Stations {
let (h, k) = (plane.height(*p), plane.height(*q));
(h.min(k), h.max(k))
}
_ => (f64::NEG_INFINITY, f64::INFINITY),
// A curve at one level (where a piece of a wall's surface stacked on another
// meets it: a boss's cylinder over the arc of the plate it stands on) crosses
// the walls there only. Any other spans every level.
_ => match index.hulls.get(i).and_then(Option::as_deref).map(|hull| level_span(hull, plane)) {
Some((lo, hi)) if hi - lo < tol => (lo, hi),
_ => (f64::NEG_INFINITY, f64::INFINITY),
},
};
cross.entry(f).or_default().push((g, lo, hi));
cross.entry(g).or_default().push((f, lo, hi));
@@ -590,6 +600,35 @@ impl Stations {
}
}
/// The most control points [`control_hull`] reads of one curve.
const MAX_HULL_POINTS: usize = 4096;
/// The points curve `c` lies within: a B-spline's control points, or a NURBS curve's where its
/// weights are positive (the curve then lies in their convex hull). `None` for a line, an
/// intersection curve, other weights or too many points.
fn control_hull(c: &mt::Curve) -> Option<Vec<Vec3>> {
match c {
mt::Curve::BSplineCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => Some(c.control_points().iter().map(|p| from_p3(*p)).collect()),
mt::Curve::NurbsCurve(c) if c.control_points().len() <= MAX_HULL_POINTS => {
c.control_points().iter().map(|p| (p.w > 0.0 && p.w.is_finite()).then(|| Vec3::new(p.x / p.w, p.y / p.w, p.z / p.w))).collect()
}
_ => None,
}
}
/// The lowest and highest levels of `points` (infinite where there are none, or one isn't
/// finite).
fn level_span(points: &[Vec3], plane: &Plane) -> (f64, f64) {
let span = points
.iter()
.map(|p| plane.height(*p))
.try_fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), h| h.is_finite().then(|| (lo.min(h), hi.max(h))));
match span {
Some((lo, hi)) if lo <= hi => (lo, hi),
_ => (f64::NEG_INFINITY, f64::INFINITY),
}
}
/// The highest level of face `f`'s vertices.
fn top_of(f: &mt::Face, plane: &Plane) -> f64 {
f.vertex_iter().map(|v| plane.height(from_p3(v.point()))).fold(f64::NEG_INFINITY, f64::max)
+14
View File
@@ -133,4 +133,18 @@ mod tests {
let st = STATE.with(Cell::get);
assert!(!st.on && !st.busy);
}
#[test]
fn a_faceted_boolean_is_boolean_time_and_a_reference_mesh_tessellation() {
use crate::{BoolOp, box_solid, faceted_boolean};
use solidcraft_geom::Vec3;
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let tool = box_solid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0)).unwrap().tessellate(0.1).unwrap();
// The thread groove's route: the faceting it does first is the boolean's time too.
let (r, t) = capture(|| faceted_boolean(&a, &tool, BoolOp::Cut, 0.01));
assert!(r.unwrap().is_some());
assert!(t.boolean_ms > 0.0 && t.tessellate_ms == 0.0, "{t:?}");
let (m, t) = capture(|| a.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE));
assert!(!m.unwrap().triangles.is_empty());
assert!(t.tessellate_ms > 0.0 && t.boolean_ms == 0.0, "{t:?}");
}
}
@@ -0,0 +1,96 @@
//! The vendored truck-geometry's second derivatives of B-spline and NURBS surfaces (`uuder`,
//! `vvder`) against finite differences of the surface itself, for degrees 2 to 5. Upstream's
//! were short of the factor p − 1, so every curvature read from a cubic surface came out half its
//! value (#763: a G2 boundary surface measured half the cylinder's curvature it does carry).
//! These pin the vendored fix, so a refresh of the crate can't quietly bring the error back.
use solidcraft_geom::Vec3;
use truck_modeling as mt;
use truck_modeling::ParametricSurface;
use crate::body::from_p3;
/// A degree-`p` × degree-`q` surface on a 5 × 6 net with an interior knot each way, bent in z.
fn surface(p: usize, q: usize) -> mt::BSplineSurface<mt::Point3> {
let knots = |d: usize, n: usize| {
let inner: Vec<f64> = (1..n - d).map(|k| k as f64 / (n - d) as f64).collect();
let mut k = vec![0.0; d + 1];
k.extend(inner);
k.extend(vec![1.0; d + 1]);
mt::KnotVec::from(k)
};
let (nu, nv) = (p + 3, q + 3);
let rows: Vec<Vec<mt::Point3>> = (0..nu)
.map(|i| {
(0..nv)
.map(|j| {
let (x, y) = (i as f64 * 2.0, j as f64 * 1.5);
mt::Point3::new(x + 0.3 * y, y, 0.4 * x * x - 0.2 * y * y + 0.1 * x * y)
})
.collect()
})
.collect();
mt::BSplineSurface::new((knots(p, nu), knots(q, nv)), rows)
}
/// Second differences of `f` along the parameter line, central inside and one-sided at the ends.
fn second(f: impl Fn(f64) -> Vec3, t: f64) -> Vec3 {
let h = 1e-4;
if t < 2.0 * h {
(f(t) * 2.0 - f(t + h) * 5.0 + f(t + 2.0 * h) * 4.0 - f(t + 3.0 * h)) / (h * h)
} else if t > 1.0 - 2.0 * h {
(f(t) * 2.0 - f(t - h) * 5.0 + f(t - 2.0 * h) * 4.0 - f(t - 3.0 * h)) / (h * h)
} else {
(f(t - h) - f(t) * 2.0 + f(t + h)) / (h * h)
}
}
fn v3(x: mt::Vector3) -> Vec3 {
Vec3::new(x.x, x.y, x.z)
}
#[test]
fn bspline_surface_second_derivatives_match_finite_differences() {
for p in 2..=5 {
for q in 2..=5 {
let s = surface(p, q);
for (u, v) in [(0.0, 0.0), (0.37, 0.0), (0.5, 0.61), (1.0, 0.2), (0.13, 1.0)] {
let uu = second(|t| from_p3(s.subs(t, v)), u);
let vv = second(|t| from_p3(s.subs(u, t)), v);
let scale = uu.len().max(vv.len()).max(1.0);
assert!(v3(s.uuder(u, v)).dist(uu) < 1e-4 * scale, "degree ({p}, {q}) uuder at ({u}, {v}): {:?} vs {uu:?}", s.uuder(u, v));
assert!(v3(s.vvder(u, v)).dist(vv) < 1e-4 * scale, "degree ({p}, {q}) vvder at ({u}, {v}): {:?} vs {vv:?}", s.vvder(u, v));
}
}
}
}
/// A rational surface's second derivatives come from its homogeneous B-spline's: they are right
/// too.
#[test]
fn nurbs_surface_second_derivatives_match_finite_differences() {
let b = surface(3, 3);
let (ku, kv) = b.knot_vecs().clone();
let rows: Vec<Vec<mt::Vector4>> = b
.control_points()
.iter()
.enumerate()
.map(|(i, row)| {
row.iter()
.enumerate()
.map(|(j, p)| {
let w = 1.0 + 0.3 * ((i + 2 * j) % 3) as f64;
mt::Vector4::new(p.x * w, p.y * w, p.z * w, w)
})
.collect()
})
.collect();
let s = mt::NurbsSurface::new(mt::BSplineSurface::new((ku, kv), rows));
for (u, v) in [(0.0, 0.0), (0.37, 0.0), (0.5, 0.61), (1.0, 0.2)] {
let uu = second(|t| from_p3(s.subs(t, v)), u);
let vv = second(|t| from_p3(s.subs(u, t)), v);
let scale = uu.len().max(vv.len()).max(1.0);
assert!(v3(s.uuder(u, v)).dist(uu) < 1e-4 * scale, "uuder at ({u}, {v}): {:?} vs {uu:?}", s.uuder(u, v));
assert!(v3(s.vvder(u, v)).dist(vv) < 1e-4 * scale, "vvder at ({u}, {v}): {:?} vs {vv:?}", s.vvder(u, v));
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ fn surface_of(shell: mt::Shell) -> Body {
/// A body from faces: a solid when they close, else a surface; with the history of the
/// operation that made the faces from `operands` as `rec` records.
fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
pub(crate) fn body_of(faces: Vec<mt::Face>, operands: &[&mt::Solid], rec: &Rebuild) -> Result<Body> {
let shell = mt::Shell::from(faces);
if shell.shell_condition() == mt::ShellCondition::Closed {
crate::record::rebuilt(mt::Solid::new_unchecked(vec![shell]), operands, rec)
+61
View File
@@ -0,0 +1,61 @@
//! A solve with geometry held moves the rest as little as it can.
use solidcraft_geom::Vec2;
use crate::{Analysis, ConstraintKind, Hold, Sketch, solve_with};
fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// A rectangle with a divider whose ends are held on the bottom and top sides (point on curve).
/// Dragging the top-left corner tilts the top side: the divider's top end slides along with it,
/// the far corner stays.
#[test]
fn a_point_held_on_a_line_slides_instead_of_swinging_the_line() {
let mut sk = Sketch::new();
let c: Vec<usize> = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
let sides: Vec<usize> = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect();
let bottom = sk.add_point(v(20.0, 0.0), None).unwrap();
let top = sk.add_point(v(20.0, 30.0), None).unwrap();
sk.add_line_pts(bottom, top, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap();
let before: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
sk.points[c[3]].pos = v(1.0, 31.0);
let rep = solve_with(&mut sk, &Hold { points: vec![c[3]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None);
assert!(rep.ok(), "{rep:?}");
let moved = |i: usize| sk.points[i].pos.dist(before[i]);
// The top side through (1, 31) and (40, 30) passes x = 20 at y ≈ 30.51: the divider's end
// needs about half a millimetre. The far corner needs nothing.
assert!(moved(top) < 0.6, "divider end moved {}", moved(top));
assert!(moved(c[2]) < 0.3, "far corner moved {}", moved(c[2]));
assert!(moved(c[1]) < 1e-6 && moved(bottom) < 1e-6, "the bottom has no reason to move");
}
/// The same rectangle, dragging the top-right corner down: the top side turns about its other
/// corner, which stays, and the divider's end slides down with it.
#[test]
fn the_far_corner_stays_when_the_side_a_point_is_held_on_turns() {
let mut sk = Sketch::new();
let c: Vec<usize> = [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
let sides: Vec<usize> = (0..4).map(|i| sk.add_line_pts(c[i], c[(i + 1) % 4], None).unwrap()).collect();
let bottom = sk.add_point(v(20.0, 0.0), None).unwrap();
let top = sk.add_point(v(20.0, 30.0), None).unwrap();
sk.add_line_pts(bottom, top, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: bottom, c: sides[0] }, None).unwrap();
sk.add_constraint(ConstraintKind::PointOnCurve { p: top, c: sides[2] }, None).unwrap();
// Down in steps, as a live drag goes: each solve starts from the last.
for k in 1..=10 {
let before: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
sk.points[c[2]].pos = v(40.0, 30.0 - k as f64);
let rep = solve_with(&mut sk, &Hold { points: vec![c[2]], radii: vec![] }, Some(&before), Analysis::StatusOnly, None);
assert!(rep.ok(), "{rep:?}");
}
let corner = sk.points[c[3]].pos;
assert!(corner.dist(v(0.0, 30.0)) < 0.1, "the far corner went to {corner:?}");
// On the turned side, near where it was.
let (end, a, b) = (sk.points[top].pos, corner, sk.points[c[2]].pos);
let off_side = ((b - a).cross(end - a) / a.dist(b)).abs();
assert!(off_side < 1e-6 && (end.x - 20.0).abs() < 1.0, "the divider's end is at {end:?}");
}
+2
View File
@@ -80,6 +80,8 @@ mod ends_tests;
#[cfg(test)]
mod font_tests;
#[cfg(test)]
mod least_motion_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod thin_tests;
+1 -1
View File
@@ -123,7 +123,7 @@ pub(super) fn dependent_blocks(
/// dimension (it constrains nothing) or an unknown index.
pub fn constraint_residual(sk: &Sketch, ci: usize) -> Option<f64> {
let c = sk.constraints.get(ci).filter(|c| !c.driven)?;
let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let fixed = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
let on_curve = points_on_curves(sk);
let b = match super::touch_point(sk, &c.kind, &on_curve) {
Some(p) => Block::TangentAt(ci, &c.kind, p),
+33 -11
View File
@@ -36,6 +36,9 @@ const MAX_COMPONENT_VARS: usize = 4_000;
const TOL: f64 = 1e-9;
/// A solution is accepted when every residual is below this (mm).
const ACCEPT: f64 = 1e-6;
/// How much less it costs a solve to move a point held on a curve than any other point: such a
/// point is placed relative to the curve, so it follows the curve rather than the curve it.
const SLIDE_WEIGHT: f64 = 0.01;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -107,6 +110,9 @@ struct Layout {
n: usize,
/// The point coordinates' variables: `0..points`.
points: usize,
/// Per variable: a coordinate of a point held on a curve (point on curve, midpoint), which
/// a solve slides along it before moving the curve (see [`SLIDE_WEIGHT`]).
slide: Vec<bool>,
}
impl Layout {
@@ -145,7 +151,7 @@ pub(crate) fn held_points(sk: &Sketch, hold: &Hold) -> Vec<bool> {
fn layout(sk: &Sketch, hold: &Hold) -> Layout {
let mut n = 0;
let pvar = held_points(sk, hold)
let pvar: Vec<Option<usize>> = held_points(sk, hold)
.iter()
.map(|f| {
if *f {
@@ -169,7 +175,19 @@ fn layout(sk: &Sketch, hold: &Hold) -> Layout {
_ => None,
})
.collect();
Layout { pvar, rvar, n, points }
let mut slide = vec![false; n];
for c in &sk.constraints {
if let ConstraintKind::PointOnCurve { p, .. } | ConstraintKind::Midpoint { p, .. } = c.kind
&& let Some(Some(v)) = pvar.get(p)
{
for k in [*v, *v + 1] {
if let Some(s) = slide.get_mut(k) {
*s = true;
}
}
}
}
Layout { pvar, rvar, n, points, slide }
}
/// Read-only view of the sketch with variable values substituted.
@@ -618,7 +636,7 @@ fn measure(s: &State, k: &ConstraintKind) -> Option<f64> {
/// What a dimension measures on the sketch as it stands (mm, radians for angles); `None` for a
/// constraint that is not a dimension.
pub fn measure_dimension(sk: &Sketch, k: &ConstraintKind) -> Option<f64> {
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
measure(&State { sk, lay: &lay, x: &[] }, k).filter(|v| v.is_finite())
}
@@ -954,13 +972,17 @@ fn lm_capped(
}
}
let mut improved = false;
// Damping floor: variables the residuals barely see must not take huge steps.
let floor = (0..n).map(|i| a.get(i, i)).fold(0.0_f64, f64::max) * 1e-4;
// Damping by distance moved, not by JᵀJ's diagonal (Marquardt): of the steps that fit the
// residuals equally well it takes the shortest, so what the constraints leave free moves
// as little as it can. (Scaled by the diagonal, a variable the residuals see weakly is
// cheap to move: a point held on a line made the line's far end swing round instead of
// the point sliding along it.) A point held on a curve is cheaper still, so it slides
// rather than the curve's ends moving.
let weight: Vec<f64> = vars.iter().map(|&v| if lay.slide.get(v).copied().unwrap_or(false) { SLIDE_WEIGHT } else { 1.0 }).collect();
for _ in 0..30 {
let mut m = a.clone();
for i in 0..n {
let d = a.get(i, i);
m.add(i, i, lambda * (d.max(floor) + 1e-9) + 1e-12);
for (i, w) in weight.iter().enumerate() {
m.add(i, i, lambda * w + 1e-12);
}
let rhs: Vec<f64> = g.iter().map(|v| -v).collect();
let Some(step) = lin_solve(m, rhs) else {
@@ -2201,7 +2223,7 @@ fn solve_inner(
None => true,
};
// Driven dimensions measure the solved geometry.
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let fixed_lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
let measured: Vec<Option<f64>> =
sk.constraints.iter().map(|c| if c.driven { measure(&State { sk, lay: &fixed_lay, x: &[] }, &c.kind) } else { None }).collect();
for (c, m) in sk.constraints.iter_mut().zip(measured) {
@@ -2265,7 +2287,7 @@ mod continuation_unit_tests {
let a = sk.add_point(Vec2::new(0.0, 0.0), None).unwrap();
let b = sk.add_point(Vec2::new(40.0, 0.0), None).unwrap();
let c = sk.add_point(Vec2::new(20.0, 1e-6), None).unwrap();
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
let turn = Orient::Turn([a, b, c]);
let state =
|sk: &Sketch| (orientation(&State { sk, lay: &lay, x: &[] }, turn), reference_orientation(&State { sk, lay: &lay, x: &[] }, turn));
@@ -2285,7 +2307,7 @@ mod continuation_unit_tests {
let q = sk.add_point(Vec2::new(10.0, 5.0), None).unwrap();
let r = sk.add_point(Vec2::new(0.0, 10.0), None).unwrap();
let l = sk.add_line(Vec2::new(-5.0, -5.0), Vec2::new(5.0, -5.0), None, None, None).unwrap();
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0 };
let lay = Layout { pvar: vec![None; sk.points.len()], rvar: vec![None; sk.curves.len()], n: 0, points: 0, slide: Vec::new() };
let sign = |sk: &Sketch, o: Orient| orientation(&State { sk, lay: &lay, x: &[] }, o);
let checks = [Orient::SideX(p, q, 10.0), Orient::SideY(p, q, 5.0), Orient::OfLine(q, l, 10.0), Orient::Turn([p, q, r])];
let before: Vec<_> = checks.iter().map(|o| sign(&sk, *o)).collect();
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn initialize_params(version: &str) -> Value {
"auth": {"terminal": true},
"_meta": {"terminal-auth": true},
},
"clientInfo": {"name": "solidcraft", "title": "SolidCraft", "version": version},
"clientInfo": {"name": "onecad", "title": "OneCAD", "version": version},
})
}
+1 -1
View File
@@ -134,7 +134,7 @@ impl AcpBackend {
Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }),
Incoming::ModelChanged(m) => out.push(Event::Models { current: m, available: Vec::new() }),
Incoming::Unsupported { rpc, method } => {
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`")));
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("OneCAD does not serve `{method}`")));
}
Incoming::Ignored => {}
}
+3 -3
View File
@@ -179,7 +179,7 @@ impl Worker {
/// One MCP request to the server; its `result`.
fn mcp_call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let Some(m) = self.mcp.as_ref() else { return Err("no SolidCraft tools in this session".into()) };
let Some(m) = self.mcp.as_ref() else { return Err("no OneCAD tools in this session".into()) };
self.next += 1;
let id = self.next;
m.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
@@ -202,9 +202,9 @@ impl Worker {
}
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
}
Ok(FromAgent::Exited(_)) => return Err("the SolidCraft tools stopped".into()),
Ok(FromAgent::Exited(_)) => return Err("the OneCAD tools stopped".into()),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => {}
Err(_) => return Err("the SolidCraft tools did not answer".into()),
Err(_) => return Err("the OneCAD tools did not answer".into()),
}
}
}
+7
View File
@@ -207,6 +207,8 @@ pub struct AgentChat {
pub agent_id: Option<String>,
/// The agent the running backend is (for permissions and "always allow").
running_id: String,
/// The popped-out chat window was given its saved place (it is open); see `panel::window_builder`.
pub(crate) window_placed: bool,
/// The agent's own modes, and the current one.
modes: Vec<(String, String)>,
mode: String,
@@ -1131,6 +1133,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
if let Some(m) = p.get("minimized").and_then(Value::as_bool) {
app.preferences.agent_chat_minimized = m;
}
if let Some(w) = p.get("popped").and_then(Value::as_bool) {
app.preferences.agent_chat_popped = w;
}
if let Some(w) = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()) {
app.preferences.agent_chat_width = (w as f32).clamp(panel::MIN_WIDTH, panel::MAX_WIDTH);
panel::forget_width(ctx);
@@ -1233,6 +1238,8 @@ pub fn status(app: &SolveApp) -> Value {
"open": c.open,
"side": app.preferences.agent_chat_side,
"minimized": app.preferences.agent_chat_minimized,
"popped": app.preferences.agent_chat_popped,
"window": app.preferences.agent_chat_window,
"width": app.preferences.agent_chat_width,
"state": c.state().id(),
"agent": c.agent,
+129 -13
View File
@@ -58,6 +58,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
toggle(app);
}
// A window that isn't drawn this frame closes; the next one opens at the saved place.
if !(app.agent_chat.open && app.preferences.agent_chat_popped) {
app.agent_chat.window_placed = false;
}
// New chat, when the chat has focus (its input, or the pointer over the panel).
let in_chat = ctx.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")))
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
@@ -72,6 +76,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ctx);
}
#[cfg(not(target_arch = "wasm32"))]
if app.preferences.agent_chat_popped {
window(app, &ctx);
return;
}
let t = Tokens::get();
let left = app.preferences.agent_chat_side == "left";
let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) };
@@ -107,7 +116,78 @@ fn request_new_chat(app: &mut SolveApp) {
}
}
/// A small drawn button: `kind` is `minimize`, `close`, `stopturn` or `expand`.
/// A saved window place (`[x, y, width, height]`) that can be used: finite, a usable size, and
/// not absurdly far away. `x` and `y` may be negative: a screen left of or above the main one.
pub fn window_place(saved: Option<[f32; 4]>) -> Option<([f32; 2], [f32; 2])> {
let [x, y, w, h] = saved?;
let usable = [x, y, w, h].iter().all(|v| v.is_finite())
&& (MIN_WIDTH..=10_000.0).contains(&w)
&& (200.0..=10_000.0).contains(&h)
&& x.abs() <= 100_000.0
&& y.abs() <= 100_000.0;
usable.then_some(([x, y], [w, h]))
}
/// The chat window's settings. The saved place and size go only to a window that is opening:
/// egui turns a changed place into a move, and the system rounds what it is given, so giving it
/// every frame made the window chase its own rounding (it shook a pixel up and down).
pub fn window_builder(app: &SolveApp) -> egui::ViewportBuilder {
let title = format!("{} — {}", app.session.doc.name, tr("ui.agent_chat"));
let builder = egui::ViewportBuilder::default().with_title(title).with_min_inner_size([MIN_WIDTH, 320.0]);
if app.agent_chat.window_placed {
return builder;
}
match window_place(app.preferences.agent_chat_window) {
Some((pos, size)) => builder.with_position(pos).with_inner_size(size),
None => builder.with_inner_size([app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH), 720.0]),
}
}
/// The chat in its own window, named after the design: a native window on the desktop, which can
/// go to another screen, or a window floating inside the app where the system can't open one.
/// The system's close button closes the chat (it opens there again); Dock puts it back.
#[cfg(not(target_arch = "wasm32"))]
fn window(app: &mut SolveApp, ctx: &egui::Context) {
let builder = window_builder(app);
app.agent_chat.window_placed = true;
// The side panel isn't drawn: its place no longer counts as "in the chat".
app.agent_chat.panel_rect = None;
ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("sc_agent_chat_window"), builder, |ui, class| {
let t = Tokens::get();
let frame = egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6));
let r = egui::CentralPanel::default().frame(frame).show(ui, |ui| body(app, ui, false));
if class == egui::ViewportClass::EmbeddedWindow {
// Floating inside the app: its handles are the main window's, as the panel's are.
crate::scenario::publish_rect("agent_chat:window", r.response.rect);
app.agent_chat.panel_rect = Some(r.response.rect);
return;
}
let ctx = ui.ctx().clone();
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
toggle(app);
}
// The whole window is the chat, so New chat works anywhere in it.
if ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
request_new_chat(app);
}
let (outer, inner, close) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect, i.viewport().close_requested()));
if let (Some(o), Some(n)) = (outer, inner) {
app.preferences.agent_chat_window = Some([o.min.x, o.min.y, n.width(), n.height()]);
}
if close {
app.agent_chat.open = false;
}
});
}
/// Whether the chat's own window should become active because the pointer is over its text box:
/// only when it is in a window of its own that isn't active and the app's main window is (never
/// taking the focus from another app).
pub fn take_focus_for_text(popped: bool, over_text: bool, chat_active: Option<bool>, main_active: Option<bool>) -> bool {
popped && over_text && chat_active == Some(false) && main_active == Some(true)
}
/// A small drawn button: `kind` is `minimize`, `close`, `newchat`, `stopturn`, `pop_out` or `dock`.
fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
let t = Tokens::get();
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
@@ -116,13 +196,29 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
}
let c = r.center();
let s = Stroke::new(1.4, t.text);
let p = ui.painter();
match kind {
"minimize" => {
ui.painter().line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s);
p.line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s);
}
"close" => {
ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
p.line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
p.line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
}
// A window with an arrow leaving it by its top right corner.
"pop_out" => {
p.line_segment([c + vec2(-1.0, -5.0), c + vec2(-5.0, -5.0)], s);
p.line_segment([c + vec2(-5.0, -5.0), c + vec2(-5.0, 5.0)], s);
p.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, 5.0)], s);
p.line_segment([c + vec2(5.0, 5.0), c + vec2(5.0, 1.0)], s);
p.line_segment([c + vec2(-1.0, 1.0), c + vec2(5.5, -5.5)], s);
p.line_segment([c + vec2(1.5, -5.5), c + vec2(5.5, -5.5)], s);
p.line_segment([c + vec2(5.5, -5.5), c + vec2(5.5, -1.5)], s);
}
// A window with its right side filled: the panel docked.
"dock" => {
p.rect_stroke(Rect::from_center_size(c, vec2(11.0, 10.0)), 1.0, s, egui::StrokeKind::Middle);
p.rect_filled(Rect::from_min_max(c + vec2(1.5, -5.0), c + vec2(5.5, 5.0)), 0.0, t.text);
}
"newchat" => {
// A speech bubble with a plus.
@@ -179,16 +275,20 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) {
}
/// The header: a grip to drag the panel to the other side, the title and state, Connect or Stop,
/// Minimise and Close.
/// Pop out (or Dock, in its own window), Minimise and Close. In its own window the system moves
/// and minimises it, so there is no grip and no Minimise.
fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
let t = Tokens::get();
let state = app.agent_chat.state();
let popped = app.preferences.agent_chat_popped;
ui.horizontal(|ui| {
let title = {
let (grip, _) = ui.allocate_exact_size(vec2(10.0, 18.0), Sense::hover());
for row in 0..3 {
for col in 0..2 {
ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim);
let (grip, _) = ui.allocate_exact_size(vec2(if popped { 0.0 } else { 10.0 }, 18.0), Sense::hover());
if !popped {
for row in 0..3 {
for col in 0..2 {
ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim);
}
}
}
let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0));
@@ -203,14 +303,16 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
}
ui.label(RichText::new(s).color(state_color(state)).small());
// Drag the header across the window to dock on the other side.
let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
crate::scenario::publish_handle("agent_chat:grip", title.center());
dock_drag(app, ui, &drag, left);
if !popped {
let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
crate::scenario::publish_handle("agent_chat:grip", title.center());
dock_drag(app, ui, &drag, left);
}
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() {
app.agent_chat.open = false;
}
if glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() {
if !popped && glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() {
app.preferences.agent_chat_minimized = true;
}
#[cfg(not(target_arch = "wasm32"))]
@@ -224,6 +326,13 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
request_new_chat(app);
}
#[cfg(not(target_arch = "wasm32"))]
{
let (kind, tip) = if popped { ("dock", "ui.agent_chat_dock") } else { ("pop_out", "ui.agent_chat_pop_out") };
if glyph_button(ui, kind, tr(tip)).clicked() {
app.preferences.agent_chat_popped = !popped;
}
}
#[cfg(not(target_arch = "wasm32"))]
{
let b = if state.running() {
ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip"))
@@ -345,6 +454,13 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
};
let r = ui.add(edit);
crate::scenario::publish_rect("agent_chat:input", r.rect);
// macOS shows a window's cursors only while it is the active window: over the text box of a
// chat window that isn't, the pointer stays an arrow. Coming from the main window, the chat
// window becomes active, so the text cursor shows and the first click places the caret.
let (chat_active, main_active) = (ui.ctx().input(|i| i.viewport().focused), ui.ctx().input_for(egui::ViewportId::ROOT, |i| i.viewport().focused));
if take_focus_for_text(app.preferences.agent_chat_popped, r.hovered(), chat_active, main_active) {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus);
}
let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
if esc {
cancel(app);
+12 -5
View File
@@ -183,12 +183,19 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
egui::Grid::new("sc_ai_panel_grid").num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| {
ui.label(tr("ui.agent_chat_side"));
ui.horizontal(|ui| {
let side = &mut app.preferences.agent_chat_side;
if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() {
*side = "left".into();
let p = &mut app.preferences;
let (left, popped) = (p.agent_chat_side == "left", p.agent_chat_popped);
if ui.radio(left && !popped, tr("ui.agent_chat_left")).clicked() {
p.agent_chat_side = "left".into();
p.agent_chat_popped = false;
}
if ui.radio(side != "left", tr("ui.agent_chat_right")).clicked() {
*side = "right".into();
if ui.radio(!left && !popped, tr("ui.agent_chat_right")).clicked() {
p.agent_chat_side = "right".into();
p.agent_chat_popped = false;
}
#[cfg(not(target_arch = "wasm32"))]
if ui.radio(popped, tr("ui.agent_chat_own_window")).on_hover_text(tr("ui.agent_chat_pop_out")).clicked() {
p.agent_chat_popped = true;
}
});
ui.end_row();
+103
View File
@@ -234,6 +234,109 @@ fn ai_features_and_the_toolbar_button_are_on_by_default() {
assert!(p.ai_features && p.ai_toolbar_button);
}
/// Pop out puts the chat in its own window (a native window on the desktop, which can go to
/// another screen; embedded here, where the test context has one viewport), and Dock puts it back
/// in the side panel. The control channel does the same.
#[test]
fn the_chat_pops_out_into_its_own_window_and_docks_back() {
use crate::scenario::{handle_at, rect_of};
let mut h = crate::scenario::Harness::new();
h.call("ui.agent_chat", json!({"open": true}));
h.frames(3);
assert!(rect_of("agent_chat:panel").is_some() && rect_of("agent_chat:window").is_none(), "docked by default");
assert!(!h.app.preferences.agent_chat_popped);
let pop = handle_at("agent_chat:pop_out").expect("a pop-out button in the header");
h.call("ui.click", json!({"x": pop.x, "y": pop.y}));
h.frames(3);
assert!(h.app.preferences.agent_chat_popped);
assert!(rect_of("agent_chat:panel").is_none(), "the side panel is gone");
assert!(rect_of("agent_chat:window").is_some(), "the chat is in its own window");
assert_eq!(status(&h.app)["popped"], json!(true));
// A window has its own minimise; the strip and the side drag are the docked panel's.
assert!(handle_at("agent_chat:minimize").is_none() && handle_at("agent_chat:grip").is_none());
let dock = handle_at("agent_chat:dock").expect("a dock button in the popped-out header");
h.call("ui.click", json!({"x": dock.x, "y": dock.y}));
h.frames(3);
assert!(!h.app.preferences.agent_chat_popped);
assert!(rect_of("agent_chat:panel").is_some() && rect_of("agent_chat:window").is_none(), "back in the side panel");
h.call("ui.agent_chat", json!({"popped": true}));
h.frames(3);
assert!(rect_of("agent_chat:window").is_some() && rect_of("agent_chat:panel").is_none());
// Closing the chat keeps where it lives: it opens popped out again.
h.call("ui.agent_chat", json!({"open": false}));
h.frames(2);
assert!(rect_of("agent_chat:window").is_none());
h.call("ui.agent_chat", json!({"open": true}));
h.frames(3);
assert!(rect_of("agent_chat:window").is_some());
}
/// The saved place is given to the window once, when it opens; after that the system owns it.
/// Given every frame, it chased the system's own rounding: a window that shook a pixel up and
/// down (686, 687, 686…) on a Retina screen.
#[test]
fn the_window_place_is_given_once_when_it_opens() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.preferences.agent_chat_window = Some([1967.0, 160.0, 569.0, 686.0]);
let first = panel::window_builder(&app);
assert_eq!(first.position, Some(egui::pos2(1967.0, 160.0)));
assert_eq!(first.inner_size, Some(egui::vec2(569.0, 686.0)));
app.agent_chat.window_placed = true;
app.preferences.agent_chat_window = Some([1967.0, 160.0, 569.0, 687.0]);
let later = panel::window_builder(&app);
assert_eq!((later.position, later.inner_size), (None, None), "nothing to move or resize once it is open");
assert!(later.title.is_some());
// Docked or closed, the next pop-out opens at the saved place again.
let mut h = crate::scenario::Harness::new();
h.call("ui.agent_chat", json!({"open": true, "popped": true}));
h.frames(2);
assert!(h.app.agent_chat.window_placed);
h.call("ui.agent_chat", json!({"popped": false}));
h.frames(2);
assert!(!h.app.agent_chat.window_placed);
h.call("ui.agent_chat", json!({"popped": true}));
h.frames(2);
h.call("ui.agent_chat", json!({"open": false}));
h.frames(2);
assert!(!h.app.agent_chat.window_placed);
}
/// macOS shows the text cursor only in the active window: the chat's own window becomes active
/// when the pointer comes over its text box from the app's main window, and never takes the focus
/// from another app.
#[test]
fn the_chat_window_takes_the_focus_for_its_text_box_from_the_main_window_only() {
assert!(panel::take_focus_for_text(true, true, Some(false), Some(true)));
assert!(!panel::take_focus_for_text(true, true, Some(false), Some(false)), "another app is active");
assert!(!panel::take_focus_for_text(true, true, Some(true), Some(false)), "already active");
assert!(!panel::take_focus_for_text(true, false, Some(false), Some(true)), "not over the text box");
assert!(!panel::take_focus_for_text(false, true, Some(false), Some(true)), "docked: the main window shows it");
assert!(!panel::take_focus_for_text(true, true, None, Some(true)), "unknown: leave it");
}
/// Whether the chat is popped out, and its window's place and size, are preferences; ones saved
/// before they existed load docked, and an unusable saved place is ignored.
#[test]
fn the_popped_out_window_and_its_place_are_remembered() {
use crate::prefs::Prefs;
let mut p = Prefs::default();
assert!(!p.agent_chat_popped && p.agent_chat_window.is_none());
p.agent_chat_popped = true;
p.agent_chat_window = Some([-1600.0, 80.0, 420.0, 700.0]);
let back: Prefs = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
assert!(back.agent_chat_popped);
assert_eq!(back.agent_chat_window, Some([-1600.0, 80.0, 420.0, 700.0]), "a place on a screen left of the main one is kept");
let mut old = serde_json::to_value(Prefs::default()).unwrap();
old.as_object_mut().unwrap().remove("agent_chat_popped");
old.as_object_mut().unwrap().remove("agent_chat_window");
let old: Prefs = serde_json::from_value(old).unwrap();
assert!(!old.agent_chat_popped && old.agent_chat_window.is_none());
assert_eq!(panel::window_place(Some([10.0, 20.0, 400.0, 600.0])), Some(([10.0, 20.0], [400.0, 600.0])));
for bad in [[f32::NAN, 0.0, 400.0, 600.0], [0.0, 0.0, 10.0, 600.0], [0.0, 0.0, 400.0, f32::INFINITY], [1e9, 0.0, 400.0, 600.0]] {
assert_eq!(panel::window_place(Some(bad)), None, "{bad:?}");
}
}
/// "Ask every time": a modelling command waits for Allow; "Always allow" lets the same command
/// through from then on for this agent. "Allow everything": even a save runs without a question.
#[test]
+127
View File
@@ -0,0 +1,127 @@
//! The Boundary Surface dialog (#763): a loop of sketch curves becomes the loop patch, edges and
//! curves across become a network with its continuity and operation, curves that meet end to end
//! are one chain, and Edit Feature brings a network back as it was.
use crate::dialogs::Kind;
use crate::dialogs_surface::Surf;
use crate::scenario::Harness;
use serde_json::{Map, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::{FeatureKind, Operation, kernel::Continuity};
use solidcraft_engine::geom::Vec3;
fn ok(h: &mut Harness) {
h.frames(2);
h.step(&json!({"widget":"OK"})).unwrap();
// An edit is applied off the UI thread: give it the frames it takes.
for _ in 0..200 {
if h.app.dialog.is_none() {
break;
}
h.frames(1);
}
assert!(h.app.dialog.is_none(), "{:?} {:?} {:?}", h.app.preview.error, h.app.dialog.as_ref().map(|d| (&d.error, &d.inputs)), h.app.session.log);
}
#[test]
fn a_loop_of_sketch_curves_is_the_loop_patch() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY","name":"Loop"})).unwrap();
for (id, a, b) in [("s1", [0, 0], [10, 0]), ("s2", [10, 0], [10, 10]), ("s3", [10, 10], [0, 10]), ("s4", [0, 10], [0, 0])] {
h.app.run("sketch.line", json!({"points":[a, b],"ids":[id]})).unwrap();
}
h.app.run("sketch.finish", json!({})).unwrap();
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
h.frames(3);
assert!(h.widget("Cross curves").is_some() && h.widget("Continuity").is_some() && h.widget("Operation").is_some());
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = ["s1", "s2", "s3", "s4"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
ok(&mut h);
let f = h.app.session.doc.features.last().unwrap();
let FeatureKind::SurfaceBoundary { curves, orders, .. } = &f.kind else { panic!("{:?}", f.kind) };
assert_eq!(curves, &["s1", "s2", "s3", "s4"]);
assert!(orders.iter().all(|o| *o == Continuity::G1), "{orders:?}");
assert_eq!(h.app.session.world_state().bodies.len(), 1);
}
/// A quarter cylinder sheet (its top edge open at y = 10, z = 0) and a 3D line above it.
fn cylinder_and_line(h: &mut Harness) -> String {
h.app.run("sketch.create", json!({"plane":"YZ","name":"Arc"})).unwrap();
let c = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
let r = h.app.run("sketch.arc.three_point", json!({"start":[0,-10],"end":[10,0],"through":[c,-c]})).unwrap();
let arc = r["created"]["curves"][0].as_str().unwrap().to_string();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("surface.extrude", json!({"sketch":"Arc","curves":[arc],"distance":10})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY","name":"Top"})).unwrap();
h.app.run("sketch.options", json!({"sketch_3d": true})).unwrap();
let r = h.app.run("sketch.line3d", json!({"points":[[0,3,12],[10,3,12]]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
r["curves"][0].as_str().unwrap().to_string()
}
#[test]
fn edges_and_curves_make_a_network_and_come_back_in_an_edit() {
let mut h = Harness::new();
let top = cylinder_and_line(&mut h);
let rim = crate::dialogs::edge_sel(&h.app.session, Vec3::new(5.0, 10.0, 0.0)).unwrap();
let Sel::Edge { body, .. } = &rim else { panic!("{rim:?}") };
let body = body.clone();
h.step(&json!({"call":"ui.start","params":{"command":"surface.boundary"}})).unwrap();
h.frames(3);
let d = h.app.dialog.as_mut().unwrap();
d.inputs[0].items = vec![rim.clone(), Sel::SketchCurve { id: top.clone() }];
let Kind::Surface(Surf::Boundary { continuity, join }) = &mut d.kind else { panic!("{:?}", d.kind) };
*continuity = 2;
*join = true;
ok(&mut h);
let name = h.app.session.doc.features.last().unwrap().name.clone();
let network = |h: &Harness| match &h.app.session.doc.find_feature(&name).unwrap().kind {
FeatureKind::SurfaceNetwork { u, v, operation } => (u.clone(), v.clone(), *operation),
k => panic!("{k:?}"),
};
let (u, v, operation) = network(&h);
assert_eq!((u.len(), v.len(), operation), (2, 0, Operation::Join));
assert_eq!(u[0].body.as_ref().map(|b| b.to_string()), Some(body.clone()));
assert_eq!(u[0].continuity, Continuity::G2);
assert_eq!(u[1].curves, vec![top.clone()]);
// Joined into the sheet: one body, two faces.
let st = h.app.session.world_state();
assert_eq!(st.bodies.len(), 1);
assert_eq!(st.body(&body).map(|b| b.body.face_count()), Some(2));
drop(st);
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
h.frames(8);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::Surface(Surf::Boundary { continuity: 2, join: true })), "{:?}", d.kind);
assert_eq!(d.inputs[0].items.len(), 2, "{:?}", d.inputs[0].items);
ok(&mut h);
let (u2, v2, op2) = network(&h);
assert_eq!((u2.len(), v2.len(), op2), (2, 0, Operation::Join));
assert_eq!((u2[0].continuity, &u2[1].curves), (Continuity::G2, &vec![top]));
}
/// Picks that meet end to end in one sketch are one curve of the network; a pick elsewhere is
/// another.
#[test]
fn curves_that_meet_are_one_chain() {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY","name":"Net"})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[5,0]],"ids":["a1"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[5,0],[10,0]],"ids":["a2"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,8],[10,8]],"ids":["b"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[0,0],[0,8]],"ids":["c"]})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,0],[10,8]],"ids":["d"]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let sketch = h.app.session.doc.find_feature("Net").unwrap().id;
let k = Surf::Boundary { continuity: 0, join: false };
let (_, mut inputs) = crate::dialogs_surface::start(&h.app, "surface.boundary").unwrap();
inputs[0].items = ["a2", "b", "a1"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
inputs[1].items = ["c", "d"].iter().map(|id| Sel::SketchCurve { id: (*id).into() }).collect();
let cmds = crate::dialogs_surface::commands(&h.app, &k, &inputs, &Map::new()).unwrap();
let (id, p) = &cmds[0];
assert_eq!(id, "surface.boundary");
assert_eq!(p["u"], json!([{"sketch": sketch, "curves": ["a2", "a1"]}, {"sketch": sketch, "curves": ["b"]}]), "{p}");
assert_eq!(p["v"], json!([{"sketch": sketch, "curves": ["c"]}, {"sketch": sketch, "curves": ["d"]}]), "{p}");
// And it builds: the chain a1–a2 is one boundary of the patch.
h.app.run("surface.boundary", p.clone()).unwrap();
assert_eq!(h.app.session.world_state().bodies.len(), 1);
}
+1 -1
View File
@@ -208,7 +208,7 @@ pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides
/// The grid and the origin's axes as the window shows them (the Grid and Origin switches).
pub(crate) fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides {
solidcraft_engine::view::Guides { grid: app.session.active_sketch.is_some() || (app.ui.show_grid && !crate::viewport::active_view(app).hide_grid), axes: app.origin_visible() }
solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }
}
/// A picture of the view as the window shows it, RGBA, drawn on the GPU with the viewport's
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn announce(app: &mut SolveApp, fresh: &[Summary]) {
}
app.crashes.open = true;
app.crashes.refresh();
app.set_status("SolidCraft closed unexpectedly or hit an internal error last time. A crash report was saved (Help > Crash Reports…).", true);
app.set_status("OneCAD closed unexpectedly or hit an internal error last time. A crash report was saved (Help > Crash Reports…).", true);
}
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
+199 -1
View File
@@ -1,5 +1,6 @@
//! Dialogs for the surface tools and face splitting (#19): Surface Extrude, Revolve, Sweep and
//! Loft, Offset and Ruled Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim and Split Face. Each has a live preview and Edit Feature.
//! Loft, Offset and Ruled Surface, Boundary Surface, Patch, Stitch, Thicken, Extend, Trim, Untrim
//! and Split Face. Each has a live preview and Edit Feature.
use serde_json::{Map, Value, json};
use solidcraft_engine::Sel;
@@ -27,6 +28,13 @@ pub enum Surf {
},
Sweep(crate::dialogs_sweep::SweepOpts),
Loft,
/// `continuity`: an index into [`PATCH_CONTINUITY`] (for a loop of sketch curves, how much of
/// the sketch's plane each side repeats; for picked edges, how the surface meets the faces
/// beside them); `join`: sew the surface into the body of its edges.
Boundary {
continuity: usize,
join: bool,
},
/// `continuity`: an index into [`PATCH_CONTINUITY`] (how a patch on edges meets the faces
/// beside its loop).
Patch {
@@ -74,6 +82,7 @@ impl Surf {
Surf::Revolve { .. } => "SURFACE REVOLVE",
Surf::Sweep(_) => "SURFACE SWEEP",
Surf::Loft => "SURFACE LOFT",
Surf::Boundary { .. } => "BOUNDARY SURFACE",
Surf::Patch { .. } => "PATCH",
Surf::Unstitch => "UNSTITCH",
Surf::ReverseNormal => "REVERSE NORMAL",
@@ -104,6 +113,9 @@ impl Surf {
/// How a patch on edges meets the faces beside its loop (labels, and the `continuity` parameter).
const PATCH_CONTINUITY: [(&str, &str); 3] = [("Position (G0)", "g0"), ("Tangent (G1)", "g1"), ("Curvature (G2)", "g2")];
/// What a boundary surface becomes: a body of its own, or part of the body of its edges.
const BOUNDARY_OPS: [&str; 2] = ["New Body", "Join"];
/// Which holes Untrim takes away: every one, the picked ones, or none (the outer boundary only).
const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"];
@@ -128,6 +140,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
]
}
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
Surf::Boundary { .. } => vec![SelInput::new("Curves", CURVES | EDGES, true), SelInput::new("Cross curves", CURVES | EDGES, true)],
Surf::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)],
@@ -155,6 +168,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
"surface.loft" => Surf::Loft,
"surface.boundary" => Surf::Boundary { continuity: 1, join: false },
"surface.patch" => Surf::Patch { continuity: 0 },
"surface.unstitch" => Surf::Unstitch,
"surface.reverse_normal" => Surf::ReverseNormal,
@@ -264,6 +278,16 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
combo(ui, "pa_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
ui.end_row();
}
Surf::Boundary { continuity, join } => {
row_label(ui, "Continuity");
combo(ui, "bo_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
ui.end_row();
row_label(ui, "Operation");
let mut op = usize::from(*join);
combo(ui, "bo_op", &BOUNDARY_OPS, &mut op);
*join = op == 1;
ui.end_row();
}
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
}
@@ -342,6 +366,98 @@ fn tool(app: &SolveApp, sel: Option<&Sel>) -> Result<(&'static str, Value), Stri
}
}
/// One curve of a boundary surface as the dialog picks it: sketch curves of one sketch, or edges
/// of one body, that meet end to end.
#[derive(Clone, Debug, PartialEq)]
enum Chain {
Sketch { sketch: u64, ids: Vec<String> },
Edges { body: String, points: Vec<Vec3> },
}
/// The picks grouped into curves: picks of one sketch (or one body) whose ends meet run on into
/// one chain, in the order they were first picked; anything else is a curve of its own.
fn chains(app: &SolveApp, picks: &[Sel]) -> Result<Vec<Chain>, String> {
let st = app.session.world_state();
// Each pick: what it belongs to (a sketch or a body), its ends, and how it joins a chain.
let mut parts: Vec<(Chain, [Vec3; 2])> = Vec::with_capacity(picks.len());
for x in picks {
match x {
Sel::SketchCurve { id } => {
let sketch = crate::dialogs::curves_sketch(app, std::slice::from_ref(id), None).ok_or("a picked curve is no longer in a sketch")?;
let ss = st.sketch(sketch).ok_or("a picked curve's sketch has not been solved")?;
let ends = if let Some(ci) = ss.sketch.curve_index(id) {
let segs = ss.sketch.segs(ci);
match (segs.first(), segs.last()) {
(Some(a), Some(b)) => [ss.plane.to_world(a.start()), ss.plane.to_world(b.end())],
_ => return Err(format!("curve `{id}` has no geometry")),
}
} else {
let w = ss.sketch.wire_index(id).and_then(|wi| ss.sketch.wires.get(wi)).ok_or("a picked curve is no longer in a sketch")?;
match (w.pts.first(), w.pts.last()) {
(Some(a), Some(b)) => [*a, *b],
_ => return Err(format!("curve `{id}` has no geometry")),
}
};
parts.push((Chain::Sketch { sketch, ids: vec![id.clone()] }, ends));
}
Sel::Edge { body, index, point } => {
let b = st.body(body).ok_or("a picked edge's body is gone")?;
let m = b.mesh();
let e = m.edges.get(*index).ok_or("a picked edge is gone")?;
let (Some(a), Some(z)) = (e.first(), e.last()) else { return Err("a picked edge has no points".into()) };
parts.push((Chain::Edges { body: body.clone(), points: vec![*point] }, [*a, *z]));
}
_ => return Err("pick sketch curves or edges".into()),
}
}
let same_source = |a: &Chain, b: &Chain| match (a, b) {
(Chain::Sketch { sketch: x, .. }, Chain::Sketch { sketch: y, .. }) => x == y,
(Chain::Edges { body: x, .. }, Chain::Edges { body: y, .. }) => x == y,
_ => false,
};
let meet = |a: &[Vec3; 2], b: &[Vec3; 2]| a.iter().any(|p| b.iter().any(|q| p.dist(*q) <= 1e-6 * (1.0 + p.len())));
// Union the picks that meet, keeping the first pick of each chain in front.
let mut group: Vec<usize> = (0..parts.len()).collect();
fn root(group: &[usize], mut i: usize) -> usize {
while let Some(&g) = group.get(i) {
if g == i {
break;
}
i = g;
}
i
}
for i in 0..parts.len() {
for j in 0..i {
let (Some((ci, ei)), Some((cj, ej))) = (parts.get(i), parts.get(j)) else { continue };
if same_source(ci, cj) && meet(ei, ej) {
let (ri, rj) = (root(&group, i), root(&group, j));
if let Some(g) = group.get_mut(ri.max(rj)) {
*g = ri.min(rj);
}
}
}
}
let mut out: Vec<(usize, Chain)> = Vec::new();
for (i, (part, _)) in parts.into_iter().enumerate() {
let r = root(&group, i);
match out.iter_mut().find(|(k, _)| *k == r) {
Some((_, Chain::Sketch { ids, .. })) => {
if let Chain::Sketch { ids: more, .. } = part {
ids.extend(more);
}
}
Some((_, Chain::Edges { points, .. })) => {
if let Chain::Edges { points: more, .. } = part {
points.extend(more);
}
}
None => out.push((r, part)),
}
}
Ok(out.into_iter().map(|(_, c)| c).collect())
}
/// The command a surface dialog makes.
pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<String, Value>) -> Result<Vec<(String, Value)>, String> {
let need = |i: usize, what: &str| if items(inputs, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) };
@@ -407,6 +523,48 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
.collect::<Result<_, String>>()?;
("surface.loft", json!({ "sections": sections }))
}
Surf::Boundary { continuity, join } => {
need(0, "the curves (sketch curves or edges)")?;
let word = PATCH_CONTINUITY.get(*continuity).map_or("g0", |(_, w)| *w);
let across = items(inputs, 1);
let picks = items(inputs, 0);
let sketch_ids: Vec<String> =
picks.iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
// Two to four sketch curves of one sketch and nothing across: the loop patch.
let looped = across.is_empty() && !*join && sketch_ids.len() == picks.len() && (2..=4).contains(&picks.len());
match looped.then(|| crate::dialogs::curves_sketch(app, &sketch_ids, None)).flatten() {
Some(sk) => {
let mut p = json!({"sketch": sk, "curves": sketch_ids});
// Orders that differ from side to side (kept from the feature) stand as they are.
if !extra.contains_key("continuity") {
p["continuity"] = json!(word);
}
("surface.boundary", p)
}
None => {
let curve = |c: &Chain| match c {
Chain::Sketch { sketch, ids } => json!({"sketch": sketch, "curves": ids}),
Chain::Edges { body, points } => {
let mut v = json!({"body": body, "edges": points.iter().map(|p| pt(*p)).collect::<Vec<_>>()});
if *continuity > 0 {
v["continuity"] = json!(word);
}
v
}
};
let u: Vec<Value> = chains(app, picks)?.iter().map(curve).collect();
let v: Vec<Value> = chains(app, across)?.iter().map(curve).collect();
let mut p = json!({"u": u});
if !v.is_empty() {
p["v"] = json!(v);
}
if *join {
p["operation"] = json!("join");
}
("surface.boundary", p)
}
}
}
Surf::Patch { continuity } => {
need(0, "a boundary (sketch profiles or a loop of edges)")?;
let profiles: Vec<(u64, usize)> =
@@ -629,6 +787,46 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
],
)
}
FeatureKind::SurfaceBoundary { curves, orders, crown, samples, .. } => {
let first = orders.first().copied().unwrap_or(solidcraft_engine::doc::kernel::Continuity::G1);
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == first.as_str());
// What the dialog does not show is kept as it was: a crown, the stations, and orders
// that differ from side to side or that the list does not offer (G3).
let mut extra = Map::new();
if *crown != 0.0 {
extra.insert("crown".into(), json!(crown));
}
if *samples != solidcraft_engine::doc::BOUNDARY_SAMPLES {
extra.insert("samples".into(), json!(samples));
}
if index.is_none() || orders.iter().any(|o| *o != first) {
extra.insert("continuity".into(), json!(orders.iter().map(|o| o.as_str()).collect::<Vec<_>>()));
}
let k = Surf::Boundary { continuity: index.unwrap_or(1), join: false };
let mut ins = inputs(&k);
if let Some(inp) = ins.first_mut() {
inp.items = curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
}
return Some((Kind::Surface(k), ins, extra));
}
FeatureKind::SurfaceNetwork { u, v, operation } => {
// A network with a point for a side has nothing to pick it with here.
if u.iter().chain(v).any(|c| c.point.is_some()) {
return None;
}
let sels = |list: &[solidcraft_engine::doc::NetworkCurve]| -> Vec<Sel> {
list.iter()
.flat_map(|c| {
let mut out: Vec<Sel> = c.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
out.extend(c.edges.iter().filter_map(edge));
out
})
.collect()
};
let g = u.iter().chain(v).find(|c| c.body.is_some()).map_or(solidcraft_engine::doc::kernel::Continuity::G0, |c| c.continuity);
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == g.as_str()).unwrap_or(0);
(Surf::Boundary { continuity: index, join: *operation == solidcraft_engine::doc::Operation::Join }, vec![sels(u), sels(v)])
}
FeatureKind::SurfaceLoft { sections, .. } => {
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch.0, sec.profiles.as_ref(), &sec.curves)).collect()])
}
+3 -3
View File
@@ -61,7 +61,7 @@ pub fn copy(app: &mut SolveApp) -> bool {
let Ok(out) = app.run("sketch.copy", json!({ "entities": entities })) else { return false };
let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0);
let k = out.get("constraints").and_then(Value::as_u64).unwrap_or(0);
let text = format!("SolidCraft sketch geometry: {n} entities, {k} constraints and dimensions");
let text = format!("OneCAD sketch geometry: {n} entities, {k} constraints and dimensions");
app.set_status(format!("Copied {n} sketch entities with {k} constraints and dimensions"), false);
leave_text(text);
return true;
@@ -74,7 +74,7 @@ pub fn copy(app: &mut SolveApp) -> bool {
let Ok(out) = app.run("timeline.copy", json!({ "features": features })) else { return false };
let names: Vec<&str> = out.get("copied").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
app.set_status(format!("Copied {}", names.join(", ")), false);
leave_text(format!("SolidCraft features: {}", names.join(", ")));
leave_text(format!("OneCAD features: {}", names.join(", ")));
true
}
@@ -92,7 +92,7 @@ pub fn cut(app: &mut SolveApp) -> bool {
let Ok(out) = app.run("sketch.cut", json!({ "entities": entities })) else { return false };
let n = out.get("curves").and_then(Value::as_u64).unwrap_or(0) + out.get("points").and_then(Value::as_u64).unwrap_or(0);
app.set_status(format!("Cut {n} sketch entities"), false);
leave_text(format!("SolidCraft sketch geometry: {n} entities"));
leave_text(format!("OneCAD sketch geometry: {n} entities"));
true
}
+1 -1
View File
@@ -89,7 +89,7 @@ fn copy_leaves_text_on_the_system_clipboard_so_paste_can_follow() {
_ => None,
}));
}
assert!(copied.iter().any(|t| t.starts_with("SolidCraft sketch geometry")), "{copied:?}");
assert!(copied.iter().any(|t| t.starts_with("OneCAD sketch geometry")), "{copied:?}");
}
#[test]
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more