Compare commits

..

61 Commits

Author SHA1 Message Date
bherbruck cfffba411e Open a design from a newer SolidCraft with a warning instead of refusing it
A newer format number or an unknown change no longer blocks opening. What
this version can read opens; a feature it can't read stays on the timeline
with an error, and fields, sections and container parts it doesn't know are
named in the reply and written back by Save, under the file's own number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:30:14 -05:00
github-actions[bot] f8a98eed33 Regenerate generated files and fold pending files [skip ci] 2026-11-04 19:59:52 +00:00
github-actions[bot] 7629421375 Regenerate generated files and fold pending files [skip ci] 2026-11-04 19:48:22 +00:00
Homero Thompson d034633265 Desktop: the file chooser says when it is not the desktop's own (#10618)
* test: the file chooser says when it is not the desktop's own (#10159)

rfd falls back to zenity in silence when the XDG portal is not there, and the
user gets a foreign window with no explanation. The host must be able to say so.

RED: the test fails with 'without a D-Bus session bus the file chooser is not the
desktop's own: the user has to be told'.

* fix: say when the file chooser is not the desktop's own (#10159)

rfd opens the file chooser through the XDG desktop portal; without the portal it
falls back to zenity, a window that does not belong to the app, and the app never
says so. The user gets a foreign window and no explanation.

The host now asks the session bus, on a thread of its own, whether the portal is
there, and keeps the answer. When a file is opened and the chooser is not the
desktop's own, the reason goes to the session log (Help > Messages), which is
where a user looks for it.

* fix: the file chooser messages read as notices, not assertions (#10159)

The maintainer asked for two things on the pull request: the strings of the
pending catalogue read like a test assertion ("the user has to be told...")
rather than a notice a person would see, and the doc comment above
desktop_chooser_fallback was there twice.

The three reasons are now plain messages, in every locale of the file: they
say what the chooser is and why, and stop there. The doc comment of the
function is now the only one, and it says what it did.
2026-11-04 19:12:58 +00:00
Yishin Li fc51df3b01 cadmpeg 0.6.7: FreeCAD placements, sphere faces and closed shells come in right (#10382)
Only the version moves: SolidCraft builds against 0.6.7 unchanged. 0.6.7 applies App::Part
and link placements once, writes a sphere's seam ring as a valid loop, and indexes the
lookups that made large assemblies slow to decode. Two real FreeCAD documents that 0.6.3
refused as invalid open with it.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:01:59 +00:00
austin62jake 8f4d7959df Agents and scripts: Parameter Sweep describes its exact parameters and reply (#10977) (#10978)
* claim: #10977

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

* Parameter Sweep: input and output schemas (#10977)

inspect.parameter_sweep gets a typed contract and reply types in
schema/parameter_sweep.rs and leaves unconverted.txt. Each key reads as
the handler reads it, with its message; the reply is built from
ParameterSweep and its item types, with the same JSON as before.

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

* Parameter Sweep schema: a missing parameter reaches the handler's message

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

* parameter_sweep schema: do not publish a null default on the required parameter

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 19:00:58 +00:00
Tury991 338fa197d3 Dimension text: round values to a step (#4896) (#11380)
* claim: #4896

* feat(doc): support dimension text rounding steps (#4896)
2026-11-04 19:00:07 +00:00
SS-EN 727bb23c7e Section Analysis: a body standing on another keeps its own cap, also in CPU pictures (#4622)
Where one body stands on another (a post on a plate), the post's bottom,
seen through the cut, and the plate's top are the same plane. In the CPU
rasterizer (headless pictures without a GPU) the plate's top won that
depth tie and showed through the post's cut. The cut now wins a tie by a
few f32 steps (DEPTH_EPS).

The viewport already draws this right since #6363 and #6728; a GPU
regression test now covers it, both creation orders, orthographic and
perspective, with both depth formats.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:52:15 +00:00
CarelT 8d6c45f640 UI kit: one shared needs-attention mark, used by the agent chat's badge (no behaviour change) (#5424)
* UI kit: one shared needs-attention mark, used by the agent chat's badge

The agent chat's "waiting" badge becomes `ui_kit::attention_mark` (a count, or
a plain dot) in `ui_kit/attention.rs`, so other things that wait for the user
(linked designs that changed, #5354) share one mark. The count is drawn
exactly as before. No change in behaviour.

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

* UI kit: sort the attention re-export

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

* ui_kit attention mark: take the corner inset from Tokens::space_3

The inset came from the agent chat panel as plain numbers, which font_sizes_and_dense_form_metrics_come_from_tokens refuses outside theme::Tokens (review on #5424). space_3 is 3.0, so nothing moves.

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

---------

Co-authored-by: Carel Theron <careltheron@Carels-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 18:52:05 +00:00
Jackson Oursland 6d16fae4ed Render preview: extract the product scene only when what it shows changes (#7465)
The live product preview's signature included the session revision, which
every command moves: a selection click extracted every body and built every
product vertex again (118 ms on a 200-body assembly) before the key comparison
skipped the upload. The signature is now the document and world state (held,
so their addresses are not reused) and every input of body visibility,
including hidden folders and the active sketch.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-04 18:51:42 +00:00
semmelrinde ef92f35b16 Sketch drag: a step solved ahead that solves becomes the sketch, one step per core (#7648) (#7649)
Past a failed step a scripted drag solves the next steps at once and takes
those that fail in a row as tried; the first that solved was then solved
again by the walk. It now becomes the sketch from its solved copy, so no
step is solved twice. A batch holds one step per core (at most 8), and on
one core or in the browser build, where a batch would run its steps one
after another, the walk solves the steps itself. The walk takes the same
steps with the same outcomes, so results and counted solves don't change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:51:32 +00:00
kaltenbrunnermarkus 232a902680 Circular pattern: cutting many copies of a hole is faster (#9007) (#9038)
* claim: #9007

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

* Circular pattern: cut copies of a hole that lie apart in one go, and check missed copies cheaply (#9007)

The check for copies that remove no material intersected every copy with
the whole body; a point test along the hole's axis (and through the middle
of the copy's overlap with the body) now proves most copies take material
and only the rest are intersected. Copies the kernel's coarse box test
takes for meeting are measured on finer meshes and, when clearly apart,
joined with join_apart and cut once.

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

* Format (#9007)

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

* Look for a copy's material through the middle of each of its faces (#9007)

A tool of two pieces (two circles cut at once) turned by 45 degrees has the
middle of its box between the pieces; the lines along the axes through it
miss both, and the copy was intersected. The lines through the middle of
each face's box run along each cylinder's axis.

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

* Circular pattern: cut copies that notch the same round face one by one (#9007)

Copies whose boxes only finer meshes show apart were cut in one go. When
several of them notch the same curved face of the target (grooves along a
bore), the result kept extra lines on that face: a revolved ring with 37
grooves came out in 184 faces instead of the 151 a one-by-one cut leaves,
at the same volume. Such copies are now cut in turn; copies that only
share flat faces (through holes in a tube wall) are still cut at once.

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

* Circular pattern: tell copies apart and find copies that cut nothing on exact shapes (#9007)

The checks this pull request added decided on meshes, which `cargo xtask
mesh-decisions` now refuses. They decide on the exact shapes instead:

- Two tools whose boxes meet are apart when their union comes out in two
  pieces that share no face, edge or vertex (crossing, nested and touching
  tools do not).
- A copy takes material when a point along its axis, the axes of its round
  faces or the middle of the box it shares with a target is inside both, by
  exact point classification; only copies that test cannot place are
  intersected.
- Copies that notch the same round face are no longer cut one by one: cut
  in one go they now leave the same faces as one by one (a revolved bore
  with 37 grooves: 80 faces either way, as on main).

The test of copies that come close now uses 8.5 mm, where they overlap; at
9 mm they are 0.012 mm apart and are cut in one go.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:42:02 +00:00
PaddleStroke cfc4908291 Sheet metal: returns on a notched tray now close at the convex corner (#8823) (#9714)
* claim: #8823

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

* Sheet metal: returns on a notched tray close at the corner away from the notch (#8823)

Closing two returns at a corner was refused when either return already lay
over another flange in the flat pattern, even far from that corner. On a tray
with a notch, the walls on either side of the notch's inner corner both unfold
into the notch, so the return on one of them lies over the other there; the
return's opposite convex corner was then left open ("closed, the flanges would
overlap in the flat pattern") and the two returns ran through each other.

A closure is now refused only when it makes the two returns overlap or adds to
what either already shares with another flange in the flat.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:41:07 +00:00
froglemonade2 1432131e95 English: command labels in title case, other labels in sentence case (#9085) (#10734)
* English: command labels in title case, other labels in sentence case (#9085)

The rest of #9085 after #9194 (the plural and the spelling) and #9907
(the Preferences page names). Command labels are in title case (422 of
430 were already); the seven in sentence case follow, and Fix/UnFix
reads Fix/Unfix. The UI text skill states the rule. The user docs name
the Preferences footer's button by its current label, Restore page
defaults (#10295).

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

* Navigation bar menu test: the echo names the command by its own label

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

* UI text skill: state the case rule in one short sentence, under the 3600 limit

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

* Localization doc: the case rule in full, with examples

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

* Localization doc: the example button is Restore page defaults now

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:39:52 +00:00
CaRLymx 35bc04847a Spanish (es-ES): catch up with en.json (2026-11-04) (#10895)
* i18n: make engine errors translatable

Engine errors (`EngineError::BadParams` and the offset/path errors the kernel
returns) were shown in English regardless of the active locale.

- Add `tr_engine_error`, which maps the known messages to `sketch.error.*`
  keys and returns anything unknown unchanged.
- Normalise the `invalid parameters for `<command>`: ` prefix before matching,
  and interpolate the offset id into the translated message.
- Route `friendly_error` through `tr_engine_error`.
- Avoid redundant status writes in `run_points`/`run_entities`.
- Ship the es-ES pending strings for the sketch tool hints.

* Spanish (es-ES): catch up with en.json

Translate the 734 messages `es.json` was missing and fix `ui.project`
("Proyecto" → "Proyectar", the action behind the sketch project tool).

The catalogue now matches `en.json` key for key (5,591 entries), keeps every
placeholder intact and stays alphabetically sorted. That includes the 61
messages added upstream since the last catch-up (92a9a5e0).

Terminology: "Turntable" → «Plato giratorio», "hover" → «pasar el puntero»,
"card" → «tarjeta».

* i18n: attribute the sketch hints and keep the offset error neutral

The pending sketch hints file adds an asset, so it gets its own row under
attribution/pending rather than editing ATTRIBUTION.md.

The engine reuses the offset-ends error for trim, extend and break, so the
message no longer tells the user to trim: it asks them to edit the source
curves instead.

* i18n: translate shortcut-started tool hints and fix the break message

Shortcuts are handled in logic(), before ui() installs the language scope, so a
tool started by its key stored the English hint even with another language
selected. Install the scope for the whole frame.

The engine has nothing to break, so say so: "nothing to break here".

* i18n: scope a tool start so its hint uses the app language

A direct start can run outside the frame scope (and a shortcut runs in logic),
so the hint was built with the thread default. Open the scope where the tool
starts.

* Spanish (es-ES): translate the 30 new messages

The blur preferences, the dimension step settings and the fillet turn modes
arrived upstream after the last catch-up.

* i18n: scope command errors and use US spelling in the hints

The native menu runs Undo and Redo before the frame scope exists, so an error
from them was translated in the thread default language.

The pending hints mixed British spellings into the English source; the English
catalog is US-spelled, so the fallbacks say center and en-GB keeps centre.
2026-11-04 18:39:43 +00:00
Mateusz Sych 06def2231d Agent chat: tell the agent the design changed by hand since its last reply (#10854) (#11144)
* claim: #10854

* Agent chat: tell the agent the design changed by hand since its last reply (#10854)

The chat remembers the design as the agent last saw it, at the end of a
turn it answered or worked in. When the next message goes and the design
is no longer that one, a note goes before the message for the agent (not
into the conversation, like the note after an interruption): the user
changed the design by hand, which parameters changed from what to what,
which features are new, edited or removed, where the History marker went,
and to read the design again before reusing positions, picks or sizes.

A new chat and a stopped backend forget it, so a session that has seen
nothing gets no note. Each list names at most 12 items and counts the
rest; names and expressions are cut at 60 characters.

* Agent chat docs: the design-changed note asks the agent to read the design again (#10854)

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-04 18:39:09 +00:00
PaddleStroke d39fd6c397 Assistant: discover Mistral Vibe and offer sign-in (#11692) (#11704)
* claim: #11692

* Assistant: discover Mistral Vibe and support its sign-in (#11692)
2026-11-04 18:38:05 +00:00
Brett Lawson 4faf63e48a Loft: profiles picked one after another in one sketch are one section (#11720) (#11906)
* Loft: profiles picked one after another in one sketch are one section (#11720)

The Loft dialog made every picked profile its own section, so two picks in
one sketch (both halves of a split outline, say) became two sections in one
plane and the loft was refused. Consecutive picks in one sketch now form one
section {sketch, profiles: [...]}; a later return to that sketch is still a
section of its own, so the there-and-back loft of #8221 is unchanged.

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

* Loft: an unchanged edit keeps a section of all profiles "all" (#11720)

An edit loads a section of "all" as each profile of its sketch; now that
those picks make one section, OK saved them as indices. Indices fill a
letter's counters that "all" keeps as holes, so a later rebuild could
change the loft. Like Extrude, Revolve and Emboss, a section that was
"all" stays "all" while the picks are unchanged and cover every profile.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:37:41 +00:00
takuto-NA 91ecea8cfd Properties: account for sheet perforations (#6025) (#6028)
* claim: #6025

* fix(engine): account for sheet perforations in physical properties

* fix(inspect): retain kernel topology counts for sheet bodies
2026-11-04 18:30:31 +00:00
PaddleStroke c8c0c28fc8 Point clouds: keep every PTS point within the import limit (#11093) (#11717)
* claim: #11093

* Point clouds: count point rows before thinning PTS imports

* Point clouds: exclude invalid coordinates from text sampling
2026-11-04 18:27:21 +00:00
PaddleStroke f91db06904 Sketch Project: links to old numbered faces can be recovered (#11666) (#11707)
* claim: #11666

* Sketch: recover legacy references on original whole faces
2026-11-04 18:24:02 +00:00
Andrzej Błasik 9df9d1fbdc fix(smlib): NURBS weights compare within rounding of the coordinates' size (#11974)
same_nurbs compared the weights of a face's NURBS form and the rebuilt
analytic surface's NURBS within an absolute 1e-12. A weight is found from
the control points, so its rounding grows with the coordinates: 3.8e-12 at
coordinates of about 320 on a cone. The face was refused with "a NURBS form
that is not its analytic surface". Weights now compare within
1e-12 * max(1, |coordinates|), as same_nurbs_curve already does for points.
2026-11-04 18:23:53 +00:00
rugbymauri 8068e8f4b8 Preferences: the theme strip's arrows page by a screenful of themes (#11434) (#11600)
* claim: #11434

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Preferences: the theme arrows move by the themes that fit in view

The previous and next arrows beside the theme cards in Preferences >
Appearance moved the row by one card per click, although their hover
text says "Previous themes" and "Next themes". With 26 themes and about
six cards in view, reaching the last theme took some twenty clicks.

The shared carousel now moves by the number of cards that fit at the
current width, so one click shows the next screenful. The last page
ends on the last card, the first page starts on the first, and the
arrow at an end stays dimmed and inert. The step follows the window
width, and the row still opens on the chosen theme.

Fixes #11434

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-04 18:21:39 +00:00
PaddleStroke 863b0777da Sketch: dragging a constrained line by its middle no longer stalls (#11696) (#11714)
* claim: #11696

* Sketch: move constrained lines that stall during dragging
2026-11-04 18:19:46 +00:00
austin62jake 80889762ce Agents and scripts: Configure, Configuration Column, Row and Rule describe their exact parameters and reply (#11895) (#11896)
* claim: #11895

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

* Configuration table: input and output schemas for Configure, Configuration Column, Row and Rule (#11895)

describe_command now gives the exact parameters and reply of the four
commands that change a design's configuration table, and the warn and
strict parameter checks use them. The handlers read their parameters
from the raw JSON, so the new contracts in schema/config.rs read each
key as its handler does, with its message: lists of names as
params::string_list (whose json_brief and MAX_STRING_LIST become
pub(crate)), exactly one column target, faces picked or a face column's
key, a row's cells, and rules (whose schema describes them; the handler
keeps its own check). What only the design can answer stays the
handler's. All four reply with ConfigTableReply, as config.table does.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 18:19:35 +00:00
rugbymauri a3f293c987 Error messages: an out-of-range value names the field that is wrong (#9625) (#9684)
* claim: #9625

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Name the value at fault when a hole, sheet rule, joint or extrude is refused (#9625)

Several refusals were right but named a check the input had passed, so
the user changed the wrong field:

- solid.hole: an unknown `fit` was reported as an unknown bolt size; it
  now says the fit must be close, normal or loose. A countersink angle
  out of range was reported as a countersink too narrow; the angle and
  the width are now checked apart. A `diameter` number out of range was
  reported as missing; it now gives the range, as other parameters do.
- Sheet rules: a thickness of 10000 mm or more said "must be positive";
  it now gives both limits. The K-factor and the bend radius are named
  on their own, and the message no longer implies a radius of 0 is
  refused.
- joint.drive: a number out of range said "numbers or expressions"; it
  now gives the range.
- Extrude: a distance past the kernel's limit said `end = 1000000000`;
  it now says the extrusion is too long and gives the limit.

Fixes #9625

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

* Refuse a too-long extrusion before the kernel and match the countersink floor (#9625)

The kernel is paused, and its `finite()` check also guards a revolve's
angle and a cylinder's and a torus's sizes, so its message must not say
"extrusion". crates/kernel is back as it was; the doc evaluator now
refuses an extrude (plain, tapered, or redefined from the Edit Extrude
dialog) whose end lies 10000000 mm or more from the sketch plane,
mirroring the kernel's limit, and says so. A cylinder of radius 2e7
keeps the kernel's own message.

The countersink check refused 0.1° while the message said "more than
0°". The floor stays (the cone grows as 1 / tan of the half angle) and
the message now names it: more than 0.115°.

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>

---------

Signed-off-by: Maurizio Monticelli <maurizio@whatwedo.ch>
2026-11-04 18:18:14 +00:00
PaddleStroke e27d17c421 Assistant: offer the Mistral AI endpoint in Add Agent (#11246) (#11715)
* claim: #11246

* Assistant: offer the Mistral AI API endpoint (#11246)
2026-11-04 18:16:34 +00:00
github-actions[bot] 52bc27aaf6 Regenerate generated files and fold pending files [skip ci] 2026-11-04 18:48:25 +00:00
github-actions[bot] c357b1565e Regenerate generated files and fold pending files [skip ci] 2026-11-04 18:40:09 +00:00
cronos de9a2bc846 Shaded with hidden edges: optionally select edges through faces (#11841)
* Allow optional hidden-edge picking in Shaded with hidden edges

Keep visible-only picking as the default. Cover the preference, hover cache,
section and body visibility, and Fillet inputs in the existing UI tests.
Use platform-absolute fixture paths in the Windows CI argument-parser test.

* Respect section caps and visible-edge ties in hidden-edge picking

* Keep section caps as occluders behind nearer kept faces
2026-11-04 18:13:40 +00:00
Mateusz Sych 6b6bd26648 Agent chat: a prompt's bubble is as tall as its message (#11882) (#11883)
* claim: #11882

* Agent chat: a prompt's bubble is as tall as its message (#11882)

The prompt's Copy message row (#10741) was laid out right to left and centred
across its height. egui gives such a row all the height left in the
conversation's view, so a prompt near the top of a conversation stretched
down over the empty view, with Copy message floating in the middle. The row
is top-aligned now.

* Agent chat: keep the test's harness bound (#11882)

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-04 18:13:19 +00:00
ABDULLAH 8239b914ba Arabic: rim term in the Lip help, Up/Down keys in the dimension step preferences, frame-rate note (#11870)
* Arabic: rim term and the shell-first step in the Lip help

* Arabic: Up/Down keys in the dimension step preferences, frame-rate note matches the English
2026-11-04 18:12:49 +00:00
Janiz-zzz e95946ac82 i18n(fi): translate the 822 messages Finnish lacks (#12036)
Added as assets/locales/pending/finnish-catch-up-2026-11.json so it
folds after merge: help texts, the redesigned Preferences (sketch,
navigation, dimension values, panel blur), fillet corner, setback and
turn options, threads, import report, browser and viewport messages.
cargo xtask i18n lists nothing missing for fi.

Co-authored-by: Janiz-zzz <267742026+Janiz-zzz@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:11:29 +00:00
cronos 46ce12b7f0 perf(ui): reuse assembly rendering caches during orbit (#2889) 2026-11-04 18:06:34 +00:00
austin62jake aba00427fa Render shots: input and output schemas for Duplicate, Rename and Remove (#11450) (#11451)
The handler's strict parameter type moves to schema/render_shots.rs as
ShotParams, with descriptions and JsonSchema, and becomes the contract of all
three commands; the handler reads the same type. It builds ShotChange instead
of a json! literal, and takes its operation as a ShotOperation enum.

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 18:02:53 +00:00
alfonsotames cccf8c1ece Snap points: a flat face offers its corners, edge middles and every hole's centre (#11476) (#11477)
* Snap points: a flat face offers its corners, edge middles and every hole's centre (#11476)

A flat face now offers its whole outline as snap points, read from the
exact faces and edges: every corner, the middle of every straight edge
and the centre of every round edge, holes included, as well as the centre
of its area. Corners on a closed circle's seam, or where the kernel split
one circle into pieces, are left out.

snap_points_from gives each point with where it came from (SnapSource):
the face it was picked on, the face's outward normal and a point inside
its edge, so a tool can keep the point by name (joint.create's edge,
corner and on) and turn a frame to the face. SnapPoint itself does not
change, so code that builds one keeps compiling.

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

* Snap points: a round face's end centres say which way their end faces (#11476)

The centres at a cylinder's or cone's two ends now carry a normal in their
SnapSource: the axis pointing out of that end, which is the normal of the
face a hole opens onto or of a pin's end. A joint origin taken from one can
then lie on that face, as the centres a flat face offers already do.

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

* Snap points: a hole centred on its face keeps its own point and rim (#11476)

Snap points were one point when they lay at one place, whatever their kind,
so a hole centred on its face was lost behind the face's centre, and with it
the rim its point keeps. Now only points of one kind are merged: the face's
centre and the hole's centre both stay, and only the hole's has its rim.

SnapSource::normal's doc now says it is also the axis out of a cylinder's or
cone's end.

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

* Snap points: a washer's two rims keep a centre each (#11476)

Centres at one place are one point only when they are on one circle: the
inner and outer rims of a washer face now keep a centre each, with its own
rim, while the pieces the kernel split one circle into are still one point.

SnapSource::normal's doc now says that at a round face's end it is the
axis out of that end of the face, which at a blind hole's bottom points
into the material below, not out of it.

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

* Snap points: rims are told apart by their radii's size, not their place (#11476)

Two centres at one place were one circle when their rims' radii differed by
less than the place's tolerance, which grows with the distance from the
origin: 60 m out, rims of 4 and 4.05 counted as one and one lost its point.
The radii are now compared with a tolerance from the radii themselves.

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

* Snap points: nearby points stay apart far from the origin (#11476)

Points of one kind at one place were merged within a tolerance of 1e-6 of
their distance from the origin, 0.06 mm at 60 m, so two small holes there
whose centres are 0.05 apart lost one centre. Places now use 1e-9 of that
distance (0.06 µm at 60 m): well above the rounding of coordinates and of a
circle fitted to an imported edge, so a point found twice is still one, and
far below any feature. The radii keep their own tolerance.

The merge is its own function, so its test can give it points directly.

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

---------

Co-authored-by: Alfonso Tamés <mufufu@Mufufu-MacBook-Pro.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:02:44 +00:00
Jackson Oursland fb80454368 User guide: window tour gets a numbered screenshot and a navigation clip (#10323)
* Book: a numbered window screenshot and a navigation clip, from a capture script

The window tour now opens with a screenshot of the real window, its parts numbered in the
order the page describes them, and a short looping clip of orbit, pan, zoom and Home.

examples/demo/doc_shots.py makes both, and any later figure: each shot is a function that
starts a fresh app (its own settings) under Xvfb, drives it over the control channel with
the demo's Demo driver, and writes a still (numbered callouts, palette-optimised PNG) or an
H.264 clip (the drawn pointer of overlay.py, short button labels, idle cut by trim_idle.py)
into docs/book/src/media/. CONTRIBUTING.md says how to redo a figure when the UI changes.

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

* doc_shots: clips keep a moment on each result; the window tour again on 0.29.0

trim_idle.py takes --hold S, the seconds of each idle stretch to keep (default 0.1, as before,
so the README demo is unchanged). The guide's clips keep 0.8 s, so a reader can see what each
step did before the next begins. The window screenshot and the navigation clip are made again
with it, on SolidCraft 0.29.0.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:33:39 +00:00
Dahak Builds f92e16fa25 Keep orbit gizmo drag tracking past the window edge (#9943) (#9978)
* Claim #9943: keep orbit gizmo drag tracking past the window edge

* Warp the orbit gizmo drag back to the window centre at the edge (#9943)

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-11-04 17:16:33 +00:00
Dahak Builds dcbedbd805 Don't blindly reopen a design that stopped the last session (#5064) (#10443)
A design that crashes or hangs the app while it is being reopened was
reopened blindly every launch, bricking the app each time. A launch that
reopens designs now marks them in the recovery folder; the marker is
cleared as each design finishes opening. A launch that finds the marker
left behind skips those designs once and says which one the last session
stopped on, so the app starts usable and the loop breaks.

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-11-04 17:16:15 +00:00
Dahak Builds 9bb216a357 Ribbon: only switch the tab when a command actually starts (#10540) (#10549)
* claim: #10540

* Ribbon: only switch the tab when a command actually starts (#10540)

select_command_tab ran before the command's own checks, so a command that
only reports a missing parameter or nothing to work on still moved the
ribbon. The ribbon now moves when the command starts and a refused command
puts the tab back, as a disabled command already never leaves it. View
display toggles and choices menus keep the ribbon's tab however they are
started, matching F6, the navigation bar and the view cube.

Written by Qwen.

* rustfmt (#10540)

Written by Qwen.

---------

Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
2026-11-04 17:16:09 +00:00
github-actions[bot] fd9b3fd9f2 Regenerate generated files and fold pending files [skip ci] 2026-11-04 18:00:54 +00:00
Mateusz Sych 6488a540c9 Windows: a lost graphics device restarts the app instead of crashing (#10240) (#11109)
* claim: #10240

* Lost GPU: a loss met by a queue write restarts the app instead of crashing (#10240)

The per-frame probe (#6528) finds a device the driver reset before the
frame's uploads, but the driver resets a device at any moment: a reset
that lands after the probe is still met first by a queue write or the
present, and wgpu-core unwraps the driver's Lost there
(PendingWrites::activate) instead of losing the device. That panic
ended the app and wrote a crash report.

- egui-wgpu: the painter runs each frame under DeviceLost::survive. A
  panic the frame meets is taken for the loss once the probe then finds
  the device lost (the probe's path loses the device and runs its lost
  callback); the painter draws nothing more and the app restarts as for
  any lost device, keeping unsaved changes for recovery. Any other panic
  carries on unwinding.
- Crash reports: a panic inside wgpu-core that unwraps Lost is the
  device loss, not a bug, and is not reported. A command that meets it
  fails; the next frame's probe restarts the app.

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-11-04 17:15:58 +00:00
froglemonade2 3e70ad9876 View navigator: right-click to choose Axes, Cube or a Custom body (#10266)
* View navigator: a custom body as the navigator, and a right-click menu to switch types

A right-click anywhere on the view navigator opens a menu with its three types: Axes, Cube and
Custom. Custom opens the Custom View Navigator panel (a feature panel with OK and Cancel): pick a
body in the view, its texture (Themed: the cube's colours; Modeled: the body's own appearance and
the view's edge colour), and whether the cube's rotation arrows and Home button go around it. The
navigator previews the panel's choices while it is open; Cancel leaves it as it was.

The custom navigator draws the body at the cube's size through a small depth-buffered raster, so
hidden faces stay hidden; hovering a face highlights it, a click looks straight at it (and frames
the model with navigator_fit on), a drag orbits. Until a body is chosen it draws the cube.

The body is kept as a packed snapshot (16-bit, base64) with the preferences and in settings
profiles, so it stays across runs and designs and travels with an exported profile. Bodies over
10,000 triangles, and long or flat ones (thinnest width under a fifth of the widest), are refused.

New command view.navigator (style, body, texture, arrows, home) does the same without opening
anything; new preferences navigator_texture, navigator_arrows and navigator_home, and a Custom
choice for navigator_style. Defaults are unchanged.

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

* Custom view navigator: a hovered face in Modeled takes the view's selection colours

In the Modeled texture the face under the pointer is filled and outlined the way the view selects
a face of the model (sel_face, sel_edge), not in the cube's hover blue and accent, which Themed
keeps.

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

* Custom view navigator: a hovered face in Modeled lights up as the view's mouse-over does

The face under the pointer keeps its own colour, a little lighter (hover_face_lift) and still
shaded, with its edges in the view's mouse-over edge colour (hover_edge), as the 3D view shows a
face under the pointer; not the selection colours. Themed keeps the cube's hover blue and accent.

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

* Custom view navigator: rotation arrows and Home on by default

navigator_arrows and navigator_home start on, so a new custom navigator has the cube's arrows and
Home button around it; the Custom View Navigator panel turns either off.

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

* Custom view navigator: the strings in every complete language, and test fixes

- The new messages in Arabic, Hebrew and Vietnamese (complete catalogs, like Japanese and Chinese).
- The navigator's raster goes through one colour helper, listed with its reason among the files
  allowed colour literals (the design's appearance shaded into pixels).
- nav_settings joins the requested-icon manifest (the view.navigator command's icon).
- A visible edge piece is kept only when the stretch seen with it, across the edge's segments, is
  at least two samples long: a hidden edge no longer shows a sliver at a silhouette corner.
- The navigator layout test treats Custom without a body as the cube it draws; the panel and
  size tests no longer assume an empty undo list, the style before opening, or a coarse sphere.

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

* Custom view navigator: profiles carry its texture, arrows and Home

Settings profiles keep a fixed set of preference keys (#9907), which the custom navigator's newer
settings are not part of. They travel in the profile's own `view_navigator` entry, next to its
body (`view_gizmo`): captured, checked (a value this version can't read is left out) and applied,
leaving the fixed key set unchanged. The cube's arrow inversion (#10286) applies to the custom
navigator's arrows too, through the shared arrows().

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

* Custom view navigator: follow main's Vec3 move and fallible world_state

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

* Custom view navigator: list view.navigator in the rig's EXEMPT, refuse a stored body that can't save again, right-click without a widget over the navigator

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

* Custom view navigator: keep the body unless picked again, save only a profile that reads back, no navigator menu through a popup

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

* Custom view navigator: refuse a stored body with a triangle past its points

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

* Custom view navigator: a stored body meets the triangle and aspect limits a picked one does

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

* Custom view navigator: a body at the 5:1 limit stays inside it once packed

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:12:27 +00:00
Igor Bronovskyi 30842efb22 Ukrainian: translate the 174 messages main gained after the 3 November sync, panel layout and the redesigned preferences included (#11064)
* i18n(uk): translate the 80 messages main gained after batch 11

* i18n(uk): translate the messages main gained after 2 November

Add Ukrainian for the 158 English messages upstream added since the last
sync, and update 10 existing translations whose English text changed.

The new messages go in assets/locales/pending/uk-sync-2-nov.json, as the
locale files are not edited for new messages. The changed English text is
updated in assets/locales/uk.json, because a pending file cannot change an
existing message.

* i18n(uk): translate the messages main gained after the 3 November sync

Add Ukrainian for the 174 English messages upstream added since the last
sync, and update 56 existing translations whose English text changed.

The new messages go in assets/locales/pending/uk-sync-2-nov.json, as the
locale files are not edited for new messages. The changed English text is
updated in assets/locales/uk.json, because a pending file cannot change an
existing message.

* i18n(uk): apply CodeRabbit review fixes

Use the catalog's established wording: 'вибрати' in the keyboard hint,
name the linked component in the double-click help, and use the command
labels 'Упор', 'Бобишка' and 'Клацкове з'єднання' in the placement group.

* i18n(uk): use "Защіпкове з'єднання" for Snap Fit

The catalog called the plastic snap-fit joint "Клацкове з'єднання", a
calque of "snap". Use the standard "Защіпкове з'єднання" and
"защіпковий гачок" in the command label, help, sample description and
the design tools placement group. The drag hint already said "защіпку".

* i18n(uk): replace calques and outdated terms

Use "збіг" for coincident, "виступ" for lip, "ступиця" for hub, "шаблон" for preset, and "наддискретизація" for supersampling. Render and panel host texts now say "рендерер" or "середовище"/"вікно" instead of "хост".
2026-11-04 17:05:44 +00:00
Colin Faulkingham 549aef17a5 Changing anti-aliasing no longer resets the saved window system (#10116) (#10147)
* claim: #10116

* fix: preserve the saved window-system preference at startup

* fix: preserve graphics preferences in safe mode
2026-11-04 16:57:30 +00:00
Daniel 4d738cd210 Sketch: an ellipse is drawn with its axes, and D measures its half-axes (#9291) (#9292)
* Sketch: an ellipse comes with its axes, and D measures its half-axes

An ellipse could only be sized by its minor radius, drawn like a circle's
radius, and there was no way to give its full axes. sketch.ellipse now also
adds the major and minor axes as construction lines through the centre.
The major axis runs from the major end to the point opposite it; the minor
one is perpendicular to it, ends on the ellipse, and both are bisected by
the centre. That adds six coordinates and six equations, so the ellipse
keeps its five degrees of freedom, and it needs no new constraint kind and
no format change.

With the axes in place every ellipse size is an ordinary dimension. D on an
axis line gives its length, the whole axis. D on the outline gives a half-
axis, the distance from the centre to an axis end, chosen the way a line
chooses horizontal or vertical but in the ellipse's own frame: text pulled
across the major axis measures along it (the major half-axis), text pulled
out past an end of it measures across it (the minor one). The end on the
text's side is used. Near the boundary between the two, a dimension being
placed keeps the half-axis it had (sketch.dimension's previous_axis, which
the Dimension tool sends), so the preview does not flip. A half-axis and
its whole axis determine each other, so the second becomes a driven
dimension through the existing redundancy path.

Deleting the ellipse leaves its axes, as deleting a Center Rectangle's
edges leaves its construction diagonals. Ellipses without axes, from
existing designs, keep the minor radius as before, and type radius still
gives it.

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

* Guide: an ellipse's axes and half-axis dimensions

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

* Sketch: test that an ellipse's half-axis preview keeps its axis near the boundary

The Dimension tool sends the half-axis its last preview chose, so near the
boundary between the major and the minor half-axis the preview keeps it.
This covers that path from the tool, from both sides of the boundary.

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

* Sketch: an ellipse's axes select and drag with it, as a rectangle's diagonals do

A double-click on a Center Rectangle's edge or diagonal selects its edges
and both diagonals, and dragging a diagonal moves the rectangle without
changing it, because its lines share corner points. An ellipse's axes are
held by constraints instead, so a double-click selected only the ellipse
(and Delete then left the axes behind), and dragging the major axis moved
the ellipse but stretched its minor half-axis.

Sketch::ellipse_axes now finds an ellipse's construction axes (the
detection sketch.dimension already used, moved into the sketch crate).
Sketch::chain treats an ellipse and its two axes as joined, so a
double-click on any of them selects all three. Dragging either axis moves
the ellipse and both axes as one, keeping both half-axes. Dragging the
outline still sets the minor half-axis.

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

* Sketch: a corner test reads its line's id instead of assuming l1

sketch.ellipse now adds two axis lines, which take the first line ids, so
the extend test reads the id of the line it draws from the reply.

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

* Sketch: an ellipse's axes cut a trim, as a Center Rectangle's diagonals do

Trim, extend and break take every other curve as a cutter, construction
lines included, so an ellipse's construction axes stop them as a Center
Rectangle's diagonals do. A test now states it for both shapes, and the
guide says it. The freeform cutter test keeps the axes it used to delete
and asserts the piece a trim removes: from where the major axis crosses
the circle to where the ellipse does.

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

* Sketch: test dragging an ellipse's outline with its axes present

Dragging the outline sets the minor half-axis, and the minor axis line,
held on the ellipse at one end, follows it; the centre and the major
half-axis stay.

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

* Sketch: other dimension types on an ellipse with axes keep the minor radius

Only auto, major and minor give a half-axis on an ellipse drawn with its
axes. Any other type (radius, diameter, horizontal and the rest) gives
what it gives on an ellipse without axes, as the parameter doc says,
instead of quietly becoming a half-axis chosen by the text. The arm for
a missing text_at is dropped: with no text the rule already picks the
minor half-axis.

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

* Sketch: test an ellipse's half-axis dead zone from the major side too

The tests only showed a previous minor half-axis being kept near the
boundary; the point they used on the major side is major by geometry
alone. A point inside the band where geometry says minor now shows a
previous major half-axis kept, in the engine and in the Dimension
tool's preview.

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

* Sketch: a placed ellipse half-axis is the one its preview showed

On an ellipse already sized, a half-axis is added as a driven dimension
on the click, and that command left out the half-axis the preview kept
near the boundary, so the click could place the other one. It now
passes it as the preview does.

The remembered half-axis also comes only from a draft whose text was
placed (one without text is the minor half-axis by default and biased
the first placement), is kept per sketch as well as per picks, and is
forgotten once a dimension is placed or the picks are cleared.

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

* Sketch: say how an ellipse's axes are recognised and what that costs

The axes are read from the constraints, with no mark in the design, so
the doc comment says which lines count, that deleting the midpoint or
point-on-curve constraint leaves an ellipse without axes, and that each
lookup scans curves times constraints.

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

* Sketch: an ellipse with one axis deleted still picks its half-axis by the text

With one construction axis deleted, D on the ellipse fell back to the minor radius wherever the
text was pulled, because the half-axis choice needed both axes. It now reads what is left of
them and chooses as before: the major half-axis goes to the major end (also from the far side,
when the point opposite is gone with its line), the minor one to its axis end, or as the minor
radius when the minor axis is the one deleted. An ellipse with neither axis keeps the minor
radius.

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

* Sketch: the one-axis half-axis test checks what is measured to

The test for an ellipse with one axis deleted checked only the reply's axis and value, which
both ends of an axis give alike. It now checks the dimension's kind and end: the axis end on
the text's side, the major end from either side once the point opposite is gone, a radius once
the minor axis is. With the major axis deleted and no text the reply names the minor axis and
measures to its end, where it used to be a radius with no axis named.

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

* Sketch: an ellipse's minor radius is drawn as its half-axis, without R

A radius on an ellipse is the minor half-axis, and it was drawn as a circle's radius: one
leader from the centre towards the text, marked R, which measures nothing a reader can see.
It is now drawn as the distance from the centre to the minor axis end on the text's side,
the way the half-axis is drawn while the ellipse has its axes, and labelled with the length
alone. This is what D gives once the minor axis line is deleted, and on an ellipse with no
axes.

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

* Sketch: an ellipse's half-axis keeps a radius's stored text

The half-axis frame read the stored text from the middle of the half-axis, so a radius placed
on an ellipse in an existing design would show its text somewhere else. The text is stored from
the centre again, as a radius's always was, and the side drawn to follows it. An unplaced one
sits where a linear dimension to the minor axis end puts its text, and stacked texts step out
from the centre as a radius's do. A view test reads the painted label, so the drawing cannot
go back to R unnoticed.

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

* Sketch: an ellipse moved, turned or scaled takes its axes along

Move, Sketch Transform and Scale moved only the curves picked, so an ellipse moved without its
axes left them where they were: its centre no longer halved them, the minor end left the
ellipse, and the sketch stayed unsolved. A Center Rectangle's diagonals share its corners and
always go along; an ellipse's axes share only the major end. The axes now go with the ellipse,
as they do when it is dragged. Copy is unchanged: it copies no constraints, for a rectangle
either.

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

* Sketch: a preference draws ellipses without their axes, as before

Drawing an ellipse with its construction axes changes what the Ellipse tool makes out of the
box, so the old ellipse stays one setting away: Preferences › Sketch › Draw ellipses with their
axes, on by default. Off, the tool draws the ellipse alone. sketch.ellipse takes axes: false
for the same, and keeps drawing the axes when it is not given.

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

* Sketch: sketch.ellipse refuses an axes value that is not true or false

A string such as "false" was read as not given, so the axes were drawn; it is now refused as
the text commands refuse a non-boolean bold or italic. The move test also covers an ellipse
whose minor axis was deleted: the major axis left goes along.

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

* Sketch: D picks a half-axis on an ellipse without axes too

An ellipse with no axis lines (both deleted, a mirrored copy, one from an older design) gave the
minor radius wherever the text was pulled, so its major half-axis could not be dimensioned on
it. The half-axis is now chosen by where the text is on every ellipse: the major one to the
ellipse's own major end, the minor one as its minor radius. With no text it is the minor
radius, as before.

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

* Sketch: with one half-axis dimensioned, D on the ellipse gives the other

D on an ellipse chose the half-axis by where the text was pulled even when that half-axis was
already held by a dimension, so the second D usually guessed the one already there and was
refused as over-constrained. With a driving dimension already on one half-axis (to an axis
end, the minor radius, or the whole axis along its line or between its ends), D with no type
now gives the other, in either order and with or without the axis lines. With both held it
chooses as before; an explicit major or minor is kept.

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

* Sketch: the held half-axis rule is documented and its cases tested

The Dimension command's parameters and the guide now say that with one half-axis held, D on
the ellipse gives the other. Tests cover the whole axis held as a distance between its ends
and as its line's length with the other axis deleted, and check that a mirrored ellipse has
no axes. The placement test undoes its first dimension, so its second half still tests where
the text is rather than the held half-axis.

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

* Sketch: Transform previews an ellipse with its axes

OK in Sketch Transform moves, turns and scales an ellipse's axes with it, but the window drew
where OK would leave only the picked curves, so the axes stayed put in the preview until OK. An
ellipse picked alone is now drawn with the axis lines it has.

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

* Sketch: the typed-radius ellipse test counts the ellipse's axes

Main's #10282 test drew an ellipse with typed radii and expected one curve; with the
ellipse_axes preference on by default the tool also draws the two axes.

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

* Sketch: an ellipse's remaining axis still drags and selects it

With one axis deleted, dragging the other axis did not hold the ellipse (with the minor axis
left, the drag squashed it), and a double-click on the ellipse or that axis selected only the
one curve. The drag and Sketch::chain now read whichever axes are left through
ellipse_axis_lines, as Transform and Move already do.

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

* Sketch: drop the unreachable third arm of the held half-axis check

A distance from the centre to the major end or the point opposite it is already matched by the first arm, so the third arm and its major_end closure never ran.

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

* Sketch: D on an ellipse's axis line is the whole axis wherever the value is pulled

An axis line took the free line's rule, so on a turned ellipse a value pulled beside or above the box the axis spans gave its horizontal or vertical distance. An ellipse's axis is a construction axis, so auto now always gives its length, which the held half-axis check already counts.

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

* Sketch: an ellipse's axis is never a side of a Fillet or Chamfer corner

A line drawn from an ellipse's major end shares that point with the major axis, so Fillet or Chamfer there took the two as a corner (two lines skip the construction filter) and rounded the line into the axis. The axes are left out of a corner, picked by its point or as a and b.

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

* Sketch: test the move reply, extend and the one-axis Transform preview for ellipse axes

The reply of sketch.move, sketch.transform and sketch.scale lists the axis lines, Extend stops at an axis, and Sketch Transform previews an ellipse with the one axis left after the other was deleted.

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

* Sketch: with Draw ellipses with their axes off, D on an ellipse is its minor radius as before

The preference brought back the old drawing only; D on an ellipse had changed with no way back. sketch.dimension takes half_axes (default true, refused when not true or false); false makes auto on an ellipse the minor radius wherever the text is. The Dimension tool and the context menu's Sketch Dimension pass it from the preference, and with it off a radius on an ellipse is drawn and labelled R as before.

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

* Sketch: read an ellipse's axes in one pass over the constraints

ellipse_axis_lines scanned every constraint for every curve, so 500 ellipses with their axes (1500 curves, 2000 constraints) took 1.6 s to read and Move on all of them 2.0 s. It now collects the lines the centre bisects first: 3 ms and 0.36 s.

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

* Sketch: the dimension and corner parameters say how an ellipse's axis lines take part

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

* Preferences: Draw ellipses with their axes says D gives the minor radius when off

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

* Sketch: ask only the ellipses centred on a line's midpoint whether it is their axis

is_ellipse_axis read every ellipse's axes to answer for one line. An axis is bisected by its ellipse's centre, so it now asks only the ellipses centred on a point that bisects the line. The answer is the same.

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

* Sketch: a horizontal or vertical dimension across a level major axis holds it for D on the ellipse

half_axes_held counted a distance, a length and a radius, but not a horizontal or vertical dimension across the major axis, which is its length when the axis lies that way. D on the outline could then take the held major half-axis and be refused as determined; it now gives the minor one. Across a slanted axis such a dimension is only a projection and does not count.

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

* Dimension: a draft on an ellipse is drawn with the axes preference it was made with

The frame read the preference as it is now, while the dimension added is the draft's.

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

* Sketch: a horizontal or vertical dimension across an upright minor axis holds it too

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

* Sketch: drop the unused Sketch::ellipse_axes; ellipse_axis_lines says how the axes are read

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

* Sketch: an ellipse's own axes do not cut it, so Trim takes a lone ellipse whole and stops at real crossings

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

* Sketch: Fillet and Chamfer offer no corner at an ellipse's axis end, as the engine takes none

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

* Sketch: a deleted ellipse leaves its centre on its axes, as a Center Rectangle's centre stays

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

* Sketch: ellipse axis docs say what they do, and the corner picker tests a curve's ends before its axis role

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

* Sketch: the trim-at-a-crossing test checks that part of the ellipse is left

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

* Sketch: an ellipse axis moved, turned or scaled on its own takes its ellipse along, as a drag does

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

* Sketch: a selection dragged by an ellipse axis takes the ellipse along, as the axis alone does

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

* Docs: the Sketch preferences page lists Draw ellipses with their axes

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

* Sketch: the ellipse axes help text says D gives the minor radius, drawn and labelled R as before, wherever the value is pulled

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:54:46 +00:00
github-actions[bot] 1cfe77bb85 Regenerate generated files and fold pending files [skip ci] 2026-11-04 17:13:13 +00:00
Nathan Dane e3f99de8af Onyx: a tactile stone-grey theme with broad subtle blur (#10338)
* Onyx: add the finished stone-grey theme and document its blur range

* Onyx: distinguish grey construction lines from gold dimensions
2026-11-04 16:35:28 +00:00
ReDead 40b1b70dec Shell: pick a body from the Browser for a closed hollow (#794) (#11441)
* claim: #794

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

* Shell: pick a body from the Browser for a closed hollow (#794)

Faces/Body accepted only faces, so a Browser body click never filled the
Shell dialog. Accept bodies, build faces:[] with that body, restore empty-face
shells as a body pick, and keep the chip/highlights on the body itself.

Co-authored-by: ReDead <ReDead@users.noreply.github.com>

* Shell: refuse faces that sit on a different body than the Browser-picked one

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

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: ReDead <ReDead@users.noreply.github.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:01:35 +00:00
github-actions[bot] 0862f1679a Regenerate generated files and fold pending files [skip ci] 2026-11-04 16:25:53 +00:00
austin62jake 2cd54b6af9 Agents and scripts: the standard part commands describe their exact parameters and reply (#11003) (#11004)
* claim: #11003

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

* Standard parts: input and output schemas (#11003)

parts.library, parts.insert, parts.fastener and parts.resize get typed
contracts and reply types in schema/parts.rs and leave unconverted.txt.
Each key reads as the handler reads it, with its message; the replies
are built from PartsLibrary, InsertedPart and ResizedPart, with the same
JSON as before.

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

* Standard parts schema: Vec3 comes from solidcore_record

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

* Standard parts schema: hole picks must start with a list

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

* parts.insert schema: the first hole pick must be a point, not any list

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 15:30:52 +00:00
N3MI-DG c9c063620a Hole: Bridged option for counterbores (#11103) (#11104)
* claim: #11103

* Hole: Bridge option for counterbores (#11103)

A counterbore hole takes an optional `bridge` length: two 3D-printing
layers that thick below the counterbore's floor, a slot along X across
the counterbore and then a square, both 0.02 mm wider than the hole, so
a printer bridges the step instead of sagging into it. Empty (the
default) keeps a plain counterbore.

The clearance keeps the bore, cut straight through both layers as for a
plain counterbore, clear of their sides: at exactly the hole's width it
ran tangent along them and broke through. The command and the geometry
refuse a layer that is not a positive length, layers that don't fit in
the hole below the counterbore, and a bridge on any other hole type.

The Hole dialog shows a Bridge field below Drill Point for counterbores.

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

* Hole: translate the Bridge label for the complete catalogs (#11103)

Arabic, Hebrew, Japanese, Vietnamese and Chinese carry every English
message, so `cargo xtask i18n --check` needs the new label in each.

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

* Hole: bridged counterbores refuse layers past a through hole's exit, too narrow a counterbore and a malformed bridge (#11103)

- Through all, the second bridge layer must end inside a body (exact point classification),
  short of the exit face; the tool's reach is no measure of the material.
- A bridged counterbore must be at least 0.04 mm wider than the hole, so the slot's ends have
  room on its wall (no square root of a negative number reaches the extrude).
- `solid.hole` refuses a `bridge` that is given but not a length expression (`true`, `[]`, `""`)
  instead of making a plain counterbore; `null` still leaves it out.
- Loop2 and Seg2 now come from solidcore_record (main moved them).
- The "nothing saved" check looks for the `"bridge"` key: `format_pending` names
  `bridged-counterbore` in every design.

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

* Unit defaults test: solid.hole has the bridge field too

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-11-04 15:24:15 +00:00
Nathan Dane 9060abc83e Sliders: keep the parent material clear beneath the track (#10375) 2026-11-04 15:17:54 +00:00
austin62jake bec762fb2b Agents and scripts: the render library browsing commands describe their exact parameters and reply (#11443) (#11444)
* claim: #11443

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

* Render library: input and output schemas for List, Describe, Targets and Preview (#11443)

Describe, Targets and Preview read their strict parameter types from
schema/render_library.rs, which become their contracts. List keeps its two
readers (the physical profiles' takes availability: null, the other
catalogs' doesn't); its contract takes what either takes. The handlers build
LibraryPage, LibraryResource, LibraryTargets and LibraryPreview instead of
json! literals; a catalog resource is read into ResourceSummary through a
small Listed reader. The physical profiles' replies keep their JSON, which the
schemas describe.

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

* Render library schema: the list says which availability each catalog takes

render.library.list has two readers: the physical profiles' takes
availability all, usable or null; the other catalogs' (and a call
without kind, which lists surfaces) take all, usable, specification or
blocked, but not null. The schema now says so with an anyOf on kind.
The type still takes what either reader takes: the readers check the
query and the page before availability, so refusing it in the type
would change which message a call with two mistakes gets in strict.
A new test checks that the schema takes ten calls exactly when the
handler does.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-04 14:50:44 +00:00
leValdemar19 826fce68eb Assembly: Move Occurrence says when the part's joints put it back (#11723) (#11791)
* claim: #11723

* Assembly: Move Occurrence says when the part's joints put it back (#11723)

A part that rigid joints hold to something grounded was moved, then put
back by the joint solve after the command, while occurrence.move still
replied with the moved transform. occurrence.move now solves the joints
before it replies. When they put the part back, the reply gives where the
part is, adds reverted: true and a warning that names the joint holding it
and what to change instead. A free chain of parts follows the move as before.

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

* Move Occurrence: the warning names the joint for a part in a repeated assembly too (#11723)

A part inside an assembly placed more than once has no path of its own, so
the reverted-move warning asked for one instead of naming the joint. It now
looks at the placement the caller named, or else the first one.

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

* Move Occurrence: list the part's joints when no single joint holds it back (#11723)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:39:17 +00:00
SS-EN 73f50d7bb4 Viewport: hidden parts no longer draw their joint-origin triads (#11294)
* Viewport: hidden occurrences draw no joint-origin triads

A named joint origin's triad (red/green/blue axes, white dot) was drawn for every
origin, so hidden inserted hardware still showed one at its placement. It now
follows the occurrence's visibility (and its parents') and the Joints folder of
the design and of the placement.

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

* Viewport: a copy's joint-origin triad follows that copy's visibility

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:24:55 +00:00
github-actions[bot] d1927aac25 Regenerate generated files and fold pending files [skip ci] 2026-11-04 13:33:45 +00:00
Brett Lawson b224ad6dae Kernel rig: the Lip refusal case matches the reworded message (#10871) (#11937)
#10871 reworded Lip's refusal on a face that is not a shelled rim to
"Lip needs the rim of a shelled body: ...", but the plastic rig case
cov-lip-on-a-face-without-an-opening-refused still looked for "pick the
rim face of a shelled body". The case now matches the new message, so the
kernel rig's plastic shard passes again on main.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:49:40 +00:00
bherbruck ca10e02573 Russian: correct the plural of degrees of freedom and make two notes match English (#11856)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:42:56 +00:00
bherbruck 5500681d6a Brand kit: logo files and a short brand guide in assets/branding (#9117)
Adds assets/branding/logo with the static mark (colour, one-colour
black and white), transparent 512/1024/2048 px PNGs, an outlined
wordmark and a horizontal lockup, all drawn from the app icon's
blue cutout layer. assets/branding/README.md covers use, colours,
clear space, minimum size and the name.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:36:16 +00:00
bherbruck fabeaa329a Desktop app: closing a design stops its kernel work, and designs build the same on any kernel (#11771)
* Desktop: closing a design stops its kernel work; same result on any kernel

A design's jobs now run under a stop token that closing or replacing the
design fires: an operation under it refuses to start, and one running ends
at solidcore's next check. The kernel it ran on is not used again. Calls
that answer from a body itself always finish, since a stopped call can leave
what it borrows half-changed, and what fills a body's shared cells is not
stopped.

Every kernel call starts clean (the same random rays, no stations from
earlier calls), so a design builds the same on any kernel of the pool, down
to its B-rep records. The pool is bounded: at most 8 kernels wait between
calls, and at 256 running calls a new call fails rather than grow or wait.

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

* Stop tokens: a copy never stops its design; a stopped job can't decide a refusal

A copy replacing its own design stopped the token it shares with the design
it came from, so that design's later work would be refused. Only the design
itself stops it now. Which face a refusal names no longer depends on whether
the job that found it had been stopped: the refusal is kept with the body,
and a stopped job's probes were all refused, which named the wrong face.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:35:39 +00:00
504 changed files with 20273 additions and 50686 deletions
+13 -1
View File
@@ -787,6 +787,18 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
| assets/locales/pending/theme-blur-ui.json | SolidCraft contributors | Original blur controls, notices and translations | MIT OR Apache-2.0 |
| crates/ui-egui/tests/fixtures/theme-blur.json | SolidCraft contributors | Original theme for blur option scenarios and manager review | MIT OR Apache-2.0 |
| assets/locales/ka.json | David Machakhelidze (devius) | Original Georgian translation of SolidCraft's English catalog, written with Claude Code from the meaning of each English string; terminology and doubtful strings decided by a native Georgian speaker; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/branding/logo/solidcraft-mark.svg | SolidCraft contributors | static SolidCraft mark: the five faces and gradients of assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg (the final frame of assets/styles/web/loading-logo.svg), cropped to the mark's bounds; original artwork | MIT OR Apache-2.0 |
| assets/branding/logo/solidcraft-mark-mono-black.svg | SolidCraft contributors | one-colour (black) version of assets/branding/logo/solidcraft-mark.svg, same paths; original artwork | MIT OR Apache-2.0 |
| assets/branding/logo/solidcraft-mark-mono-white.svg | SolidCraft contributors | one-colour (white) version of assets/branding/logo/solidcraft-mark.svg, same paths; original artwork | MIT OR Apache-2.0 |
| assets/branding/logo/solidcraft-mark-512.png | SolidCraft contributors | transparent 512 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
| assets/branding/logo/solidcraft-mark-1024.png | SolidCraft contributors | transparent 1024 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
| assets/branding/logo/solidcraft-mark-2048.png | SolidCraft contributors | transparent 2048 px render of assets/branding/logo/solidcraft-mark.svg (cairosvg); original artwork | MIT OR Apache-2.0 |
| assets/branding/logo/solidcraft-wordmark.svg | SolidCraft contributors | the name SolidCraft set like the loading logo's wordmark (bold, +0.02 em tracking, #15499e) in IBM Plex Sans Bold from assets/fonts/ibm-plex-sans (SIL OFL 1.1, IBM Corp.), outlined to paths; no font file is embedded | MIT OR Apache-2.0 |
| assets/branding/logo/solidcraft-lockup-horizontal.svg | SolidCraft contributors | assets/branding/logo/solidcraft-mark.svg beside assets/branding/logo/solidcraft-wordmark.svg, in the proportions of assets/branding/web/source/og.png; original artwork | MIT OR Apache-2.0 |
| assets/styles/ui/themes/onyx.json | SolidCraft contributors | original Onyx stone-grey palette, option overlays and semantic icon-role mapping; existing SolidCraft artwork is unchanged | MIT OR Apache-2.0 |
| docs/book/src/media/ui-tour/window.png | SolidCraft contributors | screenshot of SolidCraft 0.29.0 itself (`solidcraft --sample`, default dark theme, software renderer, 1280x800) with original numbered callouts (digits set in DejaVu Sans Bold), captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| docs/book/src/media/ui-tour/navigate.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.29.0 English desktop UI (`--sample`, default dark theme, software renderer) orbiting, panning and zooming, with the original drawn pointer of examples/demo/overlay.py and labels set in DejaVu Sans Bold, captured with examples/demo/doc_shots.py | MIT OR Apache-2.0 |
| assets/locales/pending/sketch-tool-hints.json | carlymx | Original sketch tool hints and engine error translations | MIT OR Apache-2.0 |
## Code adapted from sibling projects
@@ -838,7 +850,7 @@ into THIRD-PARTY-LICENSES.txt when `main` regenerates it.
## FreeCAD and Rhino import library
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
0.6.3 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
0.6.7 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
`cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step`
in native and browser builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no
default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS
+11 -1
View File
@@ -277,7 +277,7 @@ mean something else, it needs a format change. **Add one file, and edit nothing
change's file, or any `v<N>/` folder: those are fixed once released.
That's all. The build counts the files: while any are pending it writes the next format number,
which no release reads, so a release refuses a design that has your change instead of losing it.
which no release reads, so a release warns about a design that has your change and keeps what it can't read of it.
A file also lists the pending changes it has (`format_pending`), so a later development build
runs only the steps that file is missing. Two pull requests that each add a change merge without
a conflict. `crates/doc/src/format/pending/README.md` has an example.
@@ -472,6 +472,16 @@ the same area), first resolve the command registry and source conflicts, then ru
The generator tests cover stable ordering and additions that leave existing lines
unchanged. The initial switch to sorted rows is a one-time larger diff.
## Pictures in the user guide
The user guide's screenshots and short clips (`docs/book/src/media/`) are captured from the real
app by `examples/demo/doc_shots.py`, one function per figure. Each run starts a fresh app with its
own settings, so your preferences never show. When a change makes a figure out of date, run
`python3 examples/demo/doc_shots.py <page or shot>` (it needs Xvfb, ffmpeg, Pillow and a release
build) and commit the new file. A new figure is a new function there, an ATTRIBUTION row in
`attribution/pending/`, and a `<video>` (clips) or image link on the page. Keep a still under
about 250 KB and a clip under about 1 MB.
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
Generated
+14 -18
View File
@@ -564,9 +564,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadmpeg-codec-freecad"
version = "0.6.3"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c4a8fa61748cbb3ce80838b00a8cebfe553460583941f5aba6e2b97c16b4d37"
checksum = "6fe0ed1935ace2c203b87107a064818dec2bb2ec2f250c4ad121767f3da75d1b"
dependencies = [
"cadmpeg-container",
"cadmpeg-core",
@@ -580,9 +580,9 @@ dependencies = [
[[package]]
name = "cadmpeg-codec-rhino"
version = "0.6.3"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7af41440d4c1d69e88e612de02cc3c9bad79d03429531e640ac924fef53c6bf"
checksum = "7dd2b76bd1d9301c949fbd731fd22cff9d37d10d1f481c754b557c2187bd0171"
dependencies = [
"cadmpeg-container",
"cadmpeg-core",
@@ -599,9 +599,9 @@ dependencies = [
[[package]]
name = "cadmpeg-codec-step"
version = "0.6.3"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35e1b6d17cf26b31f59f2edab8ef5c29d2a33216c581fd913f012ad4869bd559"
checksum = "f3cbce33ed681a28b62d8b01ae64cca2b01f11f31b9a3e5e2455b9ffde915712"
dependencies = [
"base64",
"cadmpeg-container",
@@ -616,9 +616,9 @@ dependencies = [
[[package]]
name = "cadmpeg-container"
version = "0.6.3"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ab7bcef48ac7480b4917820272d85cd2c9533e17904e15b0e4d22f19a6045bd"
checksum = "03f0d1c22b875294c5fbf4a4738aec8f8c0a4ad035eb28ece404f1dec21f371a"
dependencies = [
"cadmpeg-core",
"crc32fast",
@@ -628,9 +628,9 @@ dependencies = [
[[package]]
name = "cadmpeg-core"
version = "0.6.3"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86bb7a40521832504bb1f5953a1c706b2c52aad73bf6d84e1b35e5cb0beae3f"
checksum = "795bdc6e2a10ecc0627138874c17e2948a3925cd22c6c6b0c2e4f6621d92f89c"
dependencies = [
"flate2",
"memchr",
@@ -643,9 +643,9 @@ dependencies = [
[[package]]
name = "cadmpeg-ir"
version = "0.6.3"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570a1a8c579e407a9fd7bb10fa1900f8827f1d578b8fcb4ea2a056b5f323c72"
checksum = "5592036516aaf2574b159a86a3d1ae872dc245261de86e4c0ce6773fed00a249"
dependencies = [
"base64",
"cadmpeg-core",
@@ -658,9 +658,9 @@ dependencies = [
[[package]]
name = "cadmpeg-registry"
version = "0.6.3"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2be23469a9bca0b684f1811118fffb581ebd32ded08e6cc8b0716a41f8c1377"
checksum = "fc163233d5f664337267192848505854de6aeed4843181adf87f58baddcee9a0"
dependencies = [
"cadmpeg-codec-freecad",
"cadmpeg-codec-rhino",
@@ -4219,7 +4219,6 @@ dependencies = [
"gethostname",
"gltf",
"log",
"png",
"quick-xml",
"rustix 1.1.5",
"serde",
@@ -4230,10 +4229,8 @@ dependencies = [
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-profile",
"solidcraft-sketch",
"solidcraft-testkit",
"thiserror 2.0.21",
"tiny-skia",
"zip",
]
@@ -4297,7 +4294,6 @@ dependencies = [
"harfrust",
"serde",
"serde_json",
"sha2 0.10.9",
"solidcore-record",
"solidcraft-geom",
"thiserror 2.0.21",
+3 -3
View File
@@ -81,9 +81,9 @@ gethostname = "1"
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
# features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx)
# and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through.
cadmpeg-core = "=0.6.3"
cadmpeg-ir = "=0.6.3"
cadmpeg-registry = { version = "=0.6.3", default-features = false, features = ["fcstd", "rhino", "step"] }
cadmpeg-core = "=0.6.7"
cadmpeg-ir = "=0.6.7"
cadmpeg-registry = { version = "=0.6.7", default-features = false, features = ["fcstd", "rhino", "step"] }
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std", "variable-fonts"] }
-6
View File
@@ -173,12 +173,6 @@ and the [foundation ledger](docs/drawing-foundations.md) track the bounded imple
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.
Drawing UI scaffold: the native workspace now supplies the shared four-tab ribbon,
source Browser, compact dockable sheet strip and resizable Sheet Settings/options
panel. Planned tools remain unavailable; only existing source services execute.
Drawing files and paper rendering are still deferred. See the
[UI implementation roadmap](docs/drawing-workspace-ui.md).
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;
+14 -14
View File
@@ -57,13 +57,13 @@ bytemuck_derive 1.12.1 (Zlib OR Apache-2.0 OR MIT)
byteorder 1.5.0 (Unlicense OR MIT)
byteorder-lite 0.1.0 (Unlicense OR MIT)
bytes 1.12.1 (MIT)
cadmpeg-codec-freecad 0.6.3 (Apache-2.0)
cadmpeg-codec-rhino 0.6.3 (Apache-2.0)
cadmpeg-codec-step 0.6.3 (Apache-2.0)
cadmpeg-container 0.6.3 (Apache-2.0)
cadmpeg-core 0.6.3 (Apache-2.0)
cadmpeg-ir 0.6.3 (Apache-2.0)
cadmpeg-registry 0.6.3 (Apache-2.0)
cadmpeg-codec-freecad 0.6.7 (Apache-2.0)
cadmpeg-codec-rhino 0.6.7 (Apache-2.0)
cadmpeg-codec-step 0.6.7 (Apache-2.0)
cadmpeg-container 0.6.7 (Apache-2.0)
cadmpeg-core 0.6.7 (Apache-2.0)
cadmpeg-ir 0.6.7 (Apache-2.0)
cadmpeg-registry 0.6.7 (Apache-2.0)
calloop 0.13.0 (MIT)
calloop 0.14.5 (MIT)
calloop-wayland-source 0.3.0 (MIT)
@@ -15978,13 +15978,13 @@ accesskit_windows 0.32.1: MIT OR Apache-2.0 (https://github.com/AccessKit/access
accesskit_winit 0.32.2: Apache-2.0 (https://github.com/AccessKit/accesskit)
block2 0.5.1: MIT (https://github.com/madsmtm/objc2)
block2 0.6.2: MIT (https://github.com/madsmtm/objc2)
cadmpeg-codec-freecad 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-codec-rhino 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-codec-step 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-container 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-core 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-ir 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-registry 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-codec-freecad 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-codec-rhino 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-codec-step 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-container 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-core 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-ir 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
cadmpeg-registry 0.6.7: Apache-2.0 (https://github.com/cadmpeg/cadmpeg)
clipboard-win 5.4.1: BSL-1.0 (https://github.com/DoumanAsh/clipboard-win)
dispatch 0.2.0: MIT (http://github.com/SSheldon/rust-dispatch)
dispatch2 0.3.1: Zlib OR Apache-2.0 OR MIT (https://github.com/madsmtm/objc2)
-4
View File
@@ -63,10 +63,6 @@ fn replay(report: &Value) -> Result<(), String> {
Some(d) => super::load(d)?,
None => solidcraft_engine::Session::default(),
};
// The ids the session drew from there, so the commands give the same objects the same ids.
if let Some(seed) = script["ids"].as_u64() {
s.seed_ids(seed);
}
std::panic::set_hook(Box::new(|info| {
let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string();
+18 -1
View File
@@ -318,6 +318,19 @@ fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Res
/// `load_checked`, the features computed under `validation` (`diagnostics.validation`), which
/// the session keeps.
fn load_with(path: &str, check: solidcraft_engine::schema::ParamCheck, validation: solidcraft_engine::kernel::Validation) -> Result<Session, String> {
let s = read_session(path, check, validation)?;
// A newer SolidCraft's file opens all the same; stderr says so, stdout stays the caller's.
for warning in s.doc.newer_file.iter().flat_map(|file| file.warnings()) {
eprintln!("{path}: warning: {warning}");
}
Ok(s)
}
fn read_session(
path: &str,
check: solidcraft_engine::schema::ParamCheck,
validation: solidcraft_engine::kernel::Validation,
) -> Result<Session, String> {
let mut s = Session::default();
s.param_check = check;
s.set_validation(validation);
@@ -579,7 +592,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
let mut s = open(flag(args, "--in"), positional(args), solidcraft_engine::schema::ParamCheck::Off)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
let mut out = json!({
"body_count": m["body_count"],
"bodies": m["bodies"],
"total": m["total"],
@@ -587,6 +600,10 @@ fn cmd_eval(args: &[String]) -> Result<(), String> {
"params": d["params"],
"sketches": d["sketches"],
});
if let Some(file) = &s.doc.newer_file {
out["newer_file"] = file.report();
out["warnings"] = json!(file.warnings());
}
print_json(&out, json_out)
}
+57 -22
View File
@@ -159,7 +159,7 @@ fn cli_loads_both_design_encodings_by_contents() {
}
/// #1369: a pending concept that did not land in the numbered release must not be silently
/// dropped on open/resave, in either encoding or before replacing an existing destination.
/// dropped on open/resave, in either encoding: it opens with a warning, and saving keeps it.
#[test]
fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
use solidcraft_engine::{Session, doc, io};
@@ -173,34 +173,29 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
let source = dir.0.join(format!("experimental-{version}.{extension}"));
std::fs::write(&source, &bytes).unwrap();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let original = session.doc.clone();
let original_path = session.path.clone();
let undo = session.undo.len();
let dirty = session.is_dirty();
let err = session.execute("doc.open", &json!({"path": source})).unwrap_err().to_string();
assert!(err.contains("experimental-card") && err.contains("update SolidCraft"), "{err}");
assert_eq!(session.doc, original);
assert_eq!(session.path, original_path);
assert_eq!(session.undo.len(), undo);
assert_eq!(session.is_dirty(), dirty);
// It opens, and the reply says what it is.
let reply = session.execute("doc.open", &json!({"path": source})).unwrap();
let notice = reply["newer_file"]["notice"].as_str().unwrap().to_string();
assert!(notice.contains("experimental-card") && notice.starts_with("Saved by a newer SolidCraft"), "{notice}");
assert_eq!(reply["warnings"][0], notice.as_str());
assert_eq!(session.doc.name, "Experimental");
for existing in [false, true] {
let destination = dir.0.join(format!("output-{version}-{extension}-{existing}.{extension}"));
let backup = io::backup_path(&destination);
if existing {
std::fs::write(&destination, b"existing design").unwrap();
std::fs::write(&backup, b"existing backup").unwrap();
}
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let error = String::from_utf8_lossy(&output.stderr);
assert!(error.contains("experimental-card") && error.contains("update SolidCraft"), "{error}");
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("experimental-card"), "{error}");
// Saved, it still says it has the change, and opens the same way.
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert!(saved.format_pending.iter().any(|name| name == "experimental-card"));
assert!(saved.newer_file.is_some());
if existing {
assert_eq!(std::fs::read(&destination).unwrap(), b"existing design");
assert_eq!(std::fs::read(&backup).unwrap(), b"existing backup");
} else {
assert!(!destination.exists() && !backup.exists());
assert_eq!(std::fs::read(&backup).unwrap(), b"existing design");
}
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
@@ -208,6 +203,43 @@ fn unknown_pending_concepts_preserve_the_open_design_and_output_files() {
}
}
/// `eval` and `run` on a design one format newer, with a feature kind this version doesn't have:
/// the rest builds, the reply names it, and the saved file still holds it, under the file's number.
#[test]
fn the_cli_opens_a_newer_design_and_saving_keeps_what_it_cant_read() {
use solidcraft_engine::{Session, doc, io};
let dir = Scratch::new();
let mut session = Session::default();
session.execute("solid.box", &json!({"length": 2, "width": 3, "height": 4})).unwrap();
let mut file: Value = serde_json::from_slice(&io::write_design(&session.doc)).unwrap();
let newer = doc::format::format_string(doc::format::FORMAT + 1);
file["format"] = json!(newer);
let future = json!({"id": 900, "name": "Lattice1", "type": "gyroid_lattice", "cell": 2.5});
file["features"].as_array_mut().unwrap().push(future.clone());
let source = dir.0.join("newer.json");
std::fs::write(&source, file.to_string()).unwrap();
let output = cli().args(["eval"]).arg(&source).output().unwrap();
let error = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{error}");
assert!(error.contains("warning: Saved by a newer SolidCraft") && error.contains("Lattice1"), "{error}");
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["body_count"], 1);
assert!(reply["newer_file"]["not_understood"][0].as_str().unwrap().contains("gyroid_lattice"), "{reply}");
assert!(reply["warnings"][0].as_str().unwrap().starts_with("Saved by a newer SolidCraft"), "{reply}");
for extension in ["json", "sc"] {
let destination = dir.0.join(format!("saved.{extension}"));
let output = cli().args(["run", "--in"]).arg(&source).arg("--save").arg(&destination).output().unwrap();
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
let saved = io::read_design(&std::fs::read(&destination).unwrap()).unwrap();
assert_eq!(saved.format, newer, "never a lower number");
let again: Value = serde_json::from_slice(&io::write_design(&saved)).unwrap();
assert_eq!(again["features"].as_array().unwrap().last().unwrap(), &future);
}
}
#[test]
fn known_pending_concepts_and_released_files_round_trip_with_inert_metadata() {
use solidcraft_engine::{doc, io};
@@ -460,13 +492,16 @@ fn snapshot_runs_its_script_on_the_in_design() {
}
}
/// An unknown command's error suggests the id that was meant first; other close ids (such as
/// `drawing.open`) may follow it.
/// An unknown command's error suggests the id that was meant.
#[test]
fn unknown_command_suggests_the_closest_id() {
let output = cli().args(["exec", "file.open"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`"), "{}", String::from_utf8_lossy(&output.stderr));
assert!(
String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open` or `panel.open`?"),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
/// #1833: `--timings` reports each command's time instead of the measurement, `--profile` adds
-9
View File
@@ -26,15 +26,6 @@
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/OFL.txt" data-target-path="fonts/craft-ui-sc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/CraftUI-TC.otf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/OFL.txt" data-target-path="fonts/craft-ui-tc" />
<!-- A drawing's Arabic, Korean and Chinese text: each fetched once, when a drawing needs it. -->
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-arabic/NotoSansArabic-Regular.ttf" data-target-path="fonts/noto-sans-arabic" />
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-arabic/OFL.txt" data-target-path="fonts/noto-sans-arabic" />
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-kr/NotoSansKR-Regular.otf" data-target-path="fonts/noto-sans-kr" />
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-kr/OFL.txt" data-target-path="fonts/noto-sans-kr" />
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-sc/NotoSansSC-Regular.otf" data-target-path="fonts/noto-sans-sc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-sc/OFL.txt" data-target-path="fonts/noto-sans-sc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-tc/NotoSansTC-Regular.otf" data-target-path="fonts/noto-sans-tc" />
<link data-trunk rel="copy-file" href="../../assets/fonts/noto-sans-tc/OFL.txt" data-target-path="fonts/noto-sans-tc" />
<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. -->
+3 -68
View File
@@ -23,11 +23,6 @@ mod linked;
#[cfg(any(target_arch = "wasm32", test))]
mod space_mouse;
/// The folder the page serves its fonts from: index.html copies each there, and a drawing's text
/// fetches its fonts from it (#4890).
#[cfg(any(target_arch = "wasm32", test))]
const FONTS: &str = "fonts";
/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to
/// it (#5227); plain Tab and Shift+Tab still move between the app's fields. (Ctrl+PageUp and
/// Ctrl+PageDown already reach the browser.)
@@ -292,8 +287,6 @@ mod web {
const CHINESE_TC_FONT_PATH: &str = "./fonts/CraftUI-TC.otf";
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<(String, Vec<u8>), String>>>>;
/// The fetches of drawing fonts that have finished, for the next frame to hand on.
type DrawingFontsDone = Rc<std::cell::RefCell<Vec<(solidcraft_engine::sketch::fallback::WebFont, Result<Vec<u8>, String>)>>>;
struct Shell {
app: SolveApp,
@@ -324,9 +317,6 @@ mod web {
font_fetch_kind: Option<&'static str>,
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
font_language: String,
/// The fonts a drawing's text asked for, other than Japanese, each fetched once a session.
drawing_fonts_asked: std::collections::HashSet<solidcraft_engine::sketch::fallback::WebFont>,
drawing_fonts_done: DrawingFontsDone,
space_mouse: SpaceMouse,
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
@@ -730,10 +720,6 @@ mod web {
self.chinese_kind = Some("zh-TW");
}
_ => {
solidcraft_engine::sketch::fallback::provide_web_font(
solidcraft_engine::sketch::fallback::WebFont::Japanese,
bytes.clone(),
);
self.cjk_bytes = Some(bytes);
}
}
@@ -747,9 +733,6 @@ mod web {
_ => JAPANESE_FONT_PATH,
};
log::warn!("failed to fetch script font from {path}: {err}");
if !matches!(kind, Some("zh" | "zh-TW")) {
solidcraft_engine::sketch::fallback::web_font_failed(solidcraft_engine::sketch::fallback::WebFont::Japanese);
}
false
}
};
@@ -785,12 +768,10 @@ mod web {
self.font_language = effective.to_owned();
}
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name), or a
/// drawing's text waiting for one: fetch the Japanese font, so the text does not stay tofu (#720).
/// CJK text on screen with no Japanese font yet (an English UI showing a Japanese name):
/// fetch the Japanese font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
let japanese = solidcraft_engine::sketch::fallback::web_fonts_wanted().contains(&solidcraft_engine::sketch::fallback::WebFont::Japanese);
let wanted = solidcraft_ui_egui::fonts::painted_cjk(ctx) || japanese;
if self.font.watching_text() && wanted && self.font.text_needs_font() {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
self.fetch_font(ctx, "ja");
}
}
@@ -811,33 +792,6 @@ mod web {
ctx.request_repaint();
});
}
/// The other fonts a drawing's text waits for (Arabic, Chinese, Korean): each fetched once,
/// and handed to the text when it comes. Japanese comes with the interface's CJK font.
fn fetch_drawing_fonts(&mut self, ctx: &egui::Context) {
use solidcraft_engine::sketch::fallback::{self, WebFont};
for (font, res) in self.drawing_fonts_done.borrow_mut().drain(..) {
match res {
Ok(bytes) => fallback::provide_web_font(font, bytes),
Err(err) => {
log::warn!("failed to fetch the drawing font {}/{}: {err}", crate::FONTS, font.file());
fallback::web_font_failed(font);
}
}
ctx.request_repaint();
}
for font in fallback::web_fonts_wanted() {
if font == WebFont::Japanese || !self.drawing_fonts_asked.insert(font) {
continue;
}
let (done, ctx) = (self.drawing_fonts_done.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let res = fetch_font_bytes(&format!("./{}/{}", crate::FONTS, font.file())).await;
done.borrow_mut().push((font, res));
ctx.request_repaint();
});
}
}
}
impl eframe::App for Shell {
@@ -845,7 +799,6 @@ mod web {
// First: a language another tab chose fetches its font this frame.
self.load_other_tabs_prefs();
self.tick_font(ctx);
self.fetch_drawing_fonts(ctx);
self.app.logic(ctx);
self.api.poll();
self.space_mouse.logic(&mut self.app, ctx);
@@ -1069,8 +1022,6 @@ mod web {
chinese_kind: None,
font_fetch_kind: None,
font_language,
drawing_fonts_asked: Default::default(),
drawing_fonts_done: Default::default(),
space_mouse: SpaceMouse::default(),
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
prefs_writer: crate::PrefsWriter::default(),
@@ -1236,20 +1187,4 @@ mod tests {
assert_eq!(settings_written(key, None, None), None, "clearing all storage leaves this tab's settings");
assert_eq!(settings_written(key, Some("rc:solidcraft.prefs"), Some("{}".into())), None, "another channel's settings are not loaded");
}
/// Each font a drawing's text can ask for on the web is one index.html copies, from an asset
/// in the repository, into the folder the app fetches it from.
#[test]
fn each_drawing_font_is_copied_where_it_is_fetched() {
let page = include_str!("../index.html");
let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
for font in solidcraft_engine::sketch::fallback::WebFont::ALL {
let served = format!("{FONTS}/{}", font.file());
let (folder, file) = served.rsplit_once('/').unwrap_or_default();
let copy = page.lines().find(|l| l.contains("rel=\"copy-file\"") && l.contains(&format!("/{file}\""))).unwrap_or_default();
assert!(copy.contains(&format!("data-target-path=\"{folder}\"")), "index.html doesn't copy {served}: {copy}");
let from = copy.split("href=\"").nth(1).and_then(|rest| rest.split('"').next()).unwrap_or_default();
assert!(here.join(from).is_file(), "{served}: no {from}");
}
}
}
+100
View File
@@ -0,0 +1,100 @@
//! Is the file chooser that opens the desktop's own? (#10159)
//!
//! `rfd` opens the file chooser through the XDG desktop portal; without the portal it falls
//! back to zenity, a window that does not belong to the app and that the app never mentions.
//! The desktop's own chooser is the portal one, so the app has to know when it is not the one
//! that opens, and say so in the session log.
//!
//! The session bus is asked once, on a thread of its own: a bus that answers late must not
//! hold the window. What it answered is kept, and read when a file is opened.
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
/// The portal's bus name: the desktop's own file chooser lives behind it.
const PORTAL: &str = "org.freedesktop.portal.Desktop";
/// The bus has not answered yet.
const UNKNOWN: u8 = 0;
/// There is no session bus to reach the portal on.
const NO_SESSION_BUS: u8 = 1;
/// The bus is there but the portal does not answer on it.
const NO_PORTAL_SERVICE: u8 = 2;
/// The bus is there and the portal answers on it.
const PORTAL_THERE: u8 = 3;
/// What the session bus said about the portal.
static BUS: AtomicU8 = AtomicU8::new(UNKNOWN);
/// A file chooser is opening: whether it is the desktop's own is to be said.
static OPENING: AtomicBool = AtomicBool::new(false);
/// Ask the session bus, on a thread of its own, whether the portal is there.
pub fn start(ctx: &egui::Context) {
let thread_ctx = ctx.clone();
let probe = move || {
let answer = match session_bus() {
None => NO_SESSION_BUS,
Some(connection) => match portal_on(&connection) {
true => PORTAL_THERE,
false => NO_PORTAL_SERVICE,
},
};
BUS.store(answer, Ordering::Relaxed);
// The answer is in: a file chooser waiting for it can be told now.
thread_ctx.request_repaint();
};
if let Err(e) = std::thread::Builder::new().name("desktop-chooser".into()).spawn(probe) {
log::warn!("file chooser: no thread to ask the session bus: {e}");
// Nothing will answer: the desktop's own chooser cannot be told apart from the start.
BUS.store(PORTAL_THERE, Ordering::Relaxed);
}
}
/// The session bus, when there is one to talk to.
fn session_bus() -> Option<zbus::blocking::Connection> {
zbus::blocking::Connection::session().ok()
}
/// Whether the portal answers on the bus: it is either running on it or waiting to be started.
fn portal_on(connection: &zbus::blocking::Connection) -> bool {
let Ok(bus) = zbus::blocking::fdo::DBusProxy::new(connection) else { return false };
let named = |names: Vec<zbus::names::OwnedBusName>| names.iter().any(|name| name.as_str() == PORTAL);
// A portal that is running is on the bus; one that is not running yet is still there when
// the bus can start it, which is what the portal's service file is for.
match (bus.list_names().map(named), bus.list_activatable_names().map(named)) {
(Ok(running), Ok(startable)) => running || startable,
(Ok(running), Err(_)) => running,
(Err(_), Ok(startable)) => startable,
// The bus does not answer: nothing answers on it.
(Err(_), Err(_)) => false,
}
}
/// A file chooser is opening: ask for a frame, so that the reason it is not the desktop's own
/// is said (#10159).
pub fn note_opening(ctx: &egui::Context) {
OPENING.store(true, Ordering::Relaxed);
ctx.request_repaint();
}
/// The reason the file chooser that is opening is not the desktop's own, if it is not.
///
/// The decision is the one the test states: without a session bus there is no bus to reach the
/// portal on; with a bus but no portal on it, `rfd` falls back to zenity; and under XWayland the
/// window is an X11 one while the session is a Wayland one, which the portal refuses to parent
/// its chooser to.
pub fn take_reason() -> Option<&'static str> {
if !OPENING.load(Ordering::Relaxed) {
return None;
}
let (dbus_session, portal_service) = match BUS.load(Ordering::Relaxed) {
NO_SESSION_BUS => (false, false),
NO_PORTAL_SERVICE => (true, false),
PORTAL_THERE => (true, true),
// The bus has not answered yet: wait for it, and say nothing yet.
_ => return None,
};
OPENING.store(false, Ordering::Relaxed);
let wayland_display = std::env::var_os("WAYLAND_DISPLAY").is_some();
let x11_display = super::window_system_now() == Some("x11");
super::desktop_chooser_fallback(dbus_session, portal_service, wayland_display, x11_display)
}
+213 -11
View File
@@ -23,6 +23,8 @@ mod app_icon;
mod come_forward;
mod control_server;
#[cfg(target_os = "linux")]
mod desktop_chooser;
#[cfg(target_os = "linux")]
mod desktop_theme;
#[cfg(any(target_os = "windows", target_os = "macos"))]
mod desktop_transparency;
@@ -242,6 +244,12 @@ impl eframe::App for App {
menu.tick(&mut self.solve, ctx);
}
self.solve.logic(ctx);
// The file chooser that opened is not the desktop's own: the user has to be told (#10159).
#[cfg(target_os = "linux")]
if let Some(reason) = desktop_chooser::take_reason() {
let text = self.solve.tr(reason).to_string();
self.solve.set_status(text, false);
}
self.dialog_folder.design(self.solve.session.path.as_deref());
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
if !self.solve.file_picker_busy() {
@@ -471,12 +479,19 @@ fn fullscreen_content_insets(raw: &mut egui::RawInput, title_height: f32, zoom:
fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
// The file chooser that opens may not be the desktop's own: the user is told when it does
// not (#10159).
#[cfg(target_os = "linux")]
let chooser_ctx = cc.egui_ctx.clone();
let open_parent = parent.clone();
let program_parent = parent.clone();
let file_parent = parent.clone();
let (open_folder, file_folder, save_folder) = (folder.clone(), folder.clone(), folder.clone());
Services {
pick_open: Some(Box::new(move |first: &[&str]| {
// The chooser that opens may not be the desktop's own (#10159).
#[cfg(target_os = "linux")]
desktop_chooser::note_opening(&chooser_ctx);
// The file type the command reads comes first, so the picker opens on it (#1801).
let mut dialog = open_folder.start(open_parent.clone());
if !first.is_empty() {
@@ -785,6 +800,28 @@ fn graphics() -> Option<serde_json::Value> {
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
}
fn restore_preferences(app: &mut SolveApp, stored_ui: Option<&str>, saved_graphics: Option<&serde_json::Value>, safe_mode: bool) {
if let Some(p) = stored_ui.filter(|_| !safe_mode) {
app.load_prefs(p);
}
// Even safe mode edits the real graphics.json. Keep its choices in Preferences so an
// anti-aliasing change cannot erase them; safe mode's renderer still uses its own defaults.
restore_graphics_preferences(app, saved_graphics);
}
/// Restore startup choices after loading the separately saved UI preferences.
fn restore_graphics_preferences(app: &mut SolveApp, saved: Option<&serde_json::Value>) {
app.preferences.graphics_adapter = saved.and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str)).unwrap_or_default().to_string();
// graphics.json chooses the window before app.ron is loaded. Reflect that saved choice,
// not the current session's window system (which an environment override may change).
app.preferences.window_system = saved
.and_then(|v| v.get("window_system").and_then(serde_json::Value::as_str))
.map(str::trim)
.filter(|choice| matches!(*choice, "wayland" | "x11"))
.unwrap_or("auto")
.to_string();
}
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
@@ -1003,6 +1040,8 @@ fn main() -> eframe::Result {
solidcraft_engine::crash::install(dir, build)
})
.unwrap_or_default();
// The recovery folder also holds the reopen guard marker (#5064); read once, reused below.
let recovery_dir = solidcraft_engine::recovery::default_dir();
// A crash in native code (a graphics driver) says where it happened: the exception or signal
// and the module on Windows and macOS, and on every system the startup step the app was in.
#[cfg(target_os = "windows")]
@@ -1085,6 +1124,10 @@ fn main() -> eframe::Result {
solidcraft_ui_egui::fonts::load_system_fonts_in_background(&cc.egui_ctx);
mark("fonts");
let dialog_folder = DialogFolder::default();
// The file chooser that opens may not be the desktop's own: the session bus is asked
// now, so the answer is there when a file is opened (#10159).
#[cfg(target_os = "linux")]
desktop_chooser::start(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services(cc, &dialog_folder));
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
app.custom_titlebar = custom_titlebar;
@@ -1105,9 +1148,8 @@ fn main() -> eframe::Result {
app.integrated_titlebar = cfg!(target_os = "macos");
app.cpu_meshing = safe_mode;
app.graphics_fallback = fallback_note.clone();
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
app.load_prefs(&p);
}
let stored_ui = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k)));
restore_preferences(&mut app, stored_ui.as_deref(), graphics().as_ref(), safe_mode);
if let Some(path) = &theme_file
&& let Err(e) = solidcraft_ui_egui::prefs::follow_theme_file_for_run(&mut app, path)
{
@@ -1120,10 +1162,6 @@ fn main() -> eframe::Result {
lost_at: None,
reason: String::new(),
});
if !safe_mode {
app.preferences.graphics_adapter =
graphics().and_then(|v| v.get("adapter").and_then(serde_json::Value::as_str).map(str::to_string)).unwrap_or_default();
}
if let Some(rs) = &cc.wgpu_render_state {
app.services.graphics_adapters = rs
.available_adapters
@@ -1196,8 +1234,25 @@ fn main() -> eframe::Result {
};
// No design given: the designs open at the last quit, when the preference asks (#5542).
// Not when driven over the control channel, whose clients expect a fresh design.
let files =
if files.is_empty() && !sample && control_port.is_none() { solidcraft_ui_egui::documents::designs_to_reopen(&app) } else { files };
// A design that stopped the last session while it was being reopened is skipped this
// time, so one bad file cannot brick every launch (#5064).
app.reopen_guard_dir = recovery_dir.clone();
let files = if files.is_empty() && !sample && control_port.is_none() {
let (reopen, skipped) = solidcraft_ui_egui::documents::reopen_plan(&app, recovery_dir.as_deref());
if !skipped.is_empty() {
let names: Vec<String> = skipped
.iter()
.map(|p| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()))
.collect();
notes.push(format!(
"The last session stopped while reopening {}. It was not reopened this time; open it yourself if you need it.",
names.join(", ")
));
}
reopen
} else {
files
};
for f in &files {
app.open_path_async(f);
}
@@ -1213,9 +1268,9 @@ fn main() -> eframe::Result {
app.home.show(rev);
}
// Autosave, and offer what crashed sessions left behind.
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
if let Some(dir) = recovery_dir.as_ref() {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
solidcraft_ui_egui::recovery_ui::start(&mut app, dir, std::time::Duration::from_secs_f64(every.min(1e9)));
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
if safe_mode {
app.recoverable.clear();
@@ -1403,6 +1458,47 @@ fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayla
display && !(niri && wayland_display)
}
#[cfg(all(unix, not(target_os = "macos")))]
const CHOOSER_NO_SESSION_BUS: &str = "file.open.portal_fallback.no_session_bus";
#[cfg(all(unix, not(target_os = "macos")))]
const CHOOSER_NO_PORTAL_SERVICE: &str = "file.open.portal_fallback.no_portal_service";
#[cfg(all(unix, not(target_os = "macos")))]
const CHOOSER_XWAYLAND: &str = "file.open.portal_fallback.xwayland";
/// Whether the chooser that opens is NOT the desktop's own, and why.
///
/// `rfd` opens the file chooser through the XDG desktop portal; when the portal is not there it
/// falls back to zenity, which opens a window that does not belong to SolidCraft and that the
/// application never mentions. The application has to know that: this decision is what the warning
/// is built on.
///
/// * `dbus_session`: the session bus is there (`DBUS_SESSION_BUS_ADDRESS`).
/// * `portal_service`: the file chooser portal answers on it.
/// * `wayland_display`: a Wayland session runs (`WAYLAND_DISPLAY`).
/// * `x11_display`: the window is an X11 one (`DISPLAY`, or XWayland).
///
/// `None` means the desktop's own chooser is the one that opens and there is nothing to say;
/// `Some` carries the reason to report.
#[cfg(all(unix, not(target_os = "macos")))]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn desktop_chooser_fallback(dbus_session: bool, portal_service: bool, wayland_display: bool, x11_display: bool) -> Option<&'static str> {
// Without a session bus there is no bus to reach the portal on, so the chooser is not the
// desktop's own whatever else is there.
if !dbus_session {
return Some(CHOOSER_NO_SESSION_BUS);
}
// The bus is there but the portal does not answer on it: `rfd` falls back to zenity.
if !portal_service {
return Some(CHOOSER_NO_PORTAL_SERVICE);
}
// Under XWayland the window is an X11 one while the session is a Wayland one, and the portal
// refuses to parent its chooser to an X11 window: `rfd` falls back to zenity.
if wayland_display && x11_display {
return Some(CHOOSER_XWAYLAND);
}
None
}
#[cfg(all(test, unix, not(target_os = "macos")))]
mod backend_tests {
use super::prefer_x11;
@@ -1438,6 +1534,30 @@ mod backend_tests {
assert!(prefer_x11(true, true, Some("niri"), over(Some("x11"))));
}
/// The file chooser must say when it is NOT the desktop's own: `rfd` falls back to zenity
/// when the XDG portal is not there, and the user gets a foreign window with no explanation.
#[test]
fn the_file_picker_says_when_it_is_not_the_desktops_own_chooser() {
use super::desktop_chooser_fallback;
// The normal case: the portal is there, the desktop's own chooser opens, nothing to say.
assert_eq!(desktop_chooser_fallback(true, true, true, false), None);
// No session bus: the portal can not be reached at all, so `rfd` falls back to zenity.
assert!(
desktop_chooser_fallback(false, true, true, false).is_some(),
"without a D-Bus session bus the file chooser is not the desktop's own: the user has to be told"
);
// The bus is there but the portal service is not: same fallback, same warning.
assert!(
desktop_chooser_fallback(true, false, true, false).is_some(),
"without the desktop portal service the file chooser is not the desktop's own: the user has to be told"
);
// XWayland: the parent window is an X11 one and the portal refuses it, so `rfd` falls back.
assert!(
desktop_chooser_fallback(true, true, true, true).is_some(),
"under XWayland the portal rejects the parent window: the user has to be told"
);
}
#[test]
fn the_desktops_title_bar_only_when_asked_for() {
use super::titlebar_choice;
@@ -1715,6 +1835,88 @@ mod viewport_tests {
#[cfg(test)]
mod msaa_tests {
/// Safe mode uses default UI settings but edits the real graphics.json for the next start.
#[test]
fn safe_mode_preserves_graphics_choices_when_changing_antialiasing() {
use serde_json::{Value, json};
use solidcraft_ui_egui::scenario::Harness;
for choice in ["wayland", "x11"] {
let mut h = Harness::new();
let path = h.dir.join("graphics.json");
let saved = json!({"window_system": choice, "adapter": "Selected GPU", "backend": "vulkan", "msaa": 8});
std::fs::write(&path, saved.to_string()).unwrap();
h.app.services.graphics_path = Some(path.to_string_lossy().into_owned());
let defaults = h.app.preferences.clone();
let stored_ui = json!({"preferences": {"window_system": "auto", "graphics_adapter": "Stale GPU", "msaa": 2,
"display_quality": "fine"}})
.to_string();
super::restore_preferences(&mut h.app, Some(&stored_ui), Some(&saved), true);
assert_eq!(h.app.preferences.window_system, choice);
assert_eq!(h.app.preferences.graphics_adapter, "Selected GPU");
assert_eq!(h.app.preferences.msaa, defaults.msaa, "safe mode ignores saved anti-aliasing");
assert_eq!(h.app.preferences.display_quality, defaults.display_quality, "safe mode ignores saved UI settings");
assert_eq!(serde_json::from_str::<Value>(&std::fs::read_to_string(&path).unwrap()).unwrap(), saved, "startup does not write settings");
assert_eq!(h.call("ui.prefs", json!({"open": "Graphics"}))["ok"], true);
assert_eq!(h.call("ui.prefs", json!({"msaa": 1}))["ok"], true);
assert_eq!(h.call("ui.prefs", json!({"button": "ok"}))["ok"], true);
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(after, json!({"window_system": choice, "adapter": "Selected GPU", "backend": "vulkan", "msaa": 1}));
let mut normal = Harness::new();
super::restore_preferences(&mut normal.app, Some(&stored_ui), Some(&after), false);
assert_eq!(normal.app.preferences.window_system, choice);
assert_eq!(normal.app.preferences.graphics_adapter, "Selected GPU");
assert_eq!(normal.app.preferences.display_quality, "fine", "normal startup still restores UI settings");
}
}
#[test]
fn missing_or_invalid_startup_window_system_uses_automatic() {
use serde_json::json;
for saved in [None, Some(json!(null)), Some(json!({})), Some(json!({"window_system": 7})), Some(json!({"window_system": "unknown"}))] {
let mut app = super::SolveApp::new(super::Session::default(), super::Services::default());
app.preferences.window_system = "wayland".into();
super::restore_graphics_preferences(&mut app, saved.as_ref());
assert_eq!(app.preferences.window_system, "auto", "{saved:?}");
}
}
/// A manual graphics.json choice wins over stale app.ron preferences and survives an
/// unrelated graphics edit (#10116), including on a fresh launch with no saved UI state.
#[test]
fn startup_window_system_survives_changing_antialiasing() {
use serde_json::{Value, json};
use solidcraft_ui_egui::scenario::Harness;
for choice in ["wayland", "x11", "auto", " wayland ", " x11 "] {
for stale in [None, Some("auto"), Some("wayland"), Some("x11")] {
let mut h = Harness::new();
if let Some(stale) = stale {
h.app.load_prefs(&json!({"preferences": {"window_system": stale}}).to_string());
}
let path = h.dir.join("graphics.json");
std::fs::write(&path, json!({"window_system": choice, "backend": "vulkan", "adapter": "Selected GPU", "msaa": 4}).to_string())
.unwrap();
h.app.services.graphics_path = Some(path.to_string_lossy().into_owned());
let saved: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
super::restore_graphics_preferences(&mut h.app, Some(&saved));
let result = h.call("ui.prefs", json!({"msaa": 2}));
assert_eq!(result["ok"], true, "{result}");
let after: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(after["window_system"], choice.trim(), "saved choice {choice:?}, stale UI choice {stale:?}");
assert_eq!(result["result"]["window_system"], choice.trim());
assert_eq!(after["msaa"], 2);
assert_eq!(after["backend"], "vulkan");
assert_eq!(after["adapter"], "Selected GPU");
}
}
}
#[test]
fn first_launch_adopts_the_previous_release_settings() {
let d = std::env::temp_dir().join(format!("solidcraft-adopt-settings-{}", std::process::id()));
+3 -2
View File
@@ -17,12 +17,13 @@ assets/
ui/design/tools/ # authoring bases, checks and included review app
ui/source/ # Rust viewport glyphs, markers and navigation
branding/web/source/og.png # shared social preview card
branding/logo/ # print/web logo files; guide in branding/README.md
styles/ui/palettes.rs # light/dark defaults
styles/web/loading.css # web loading/canvas styles
appearances/library.rs # built-in visual presets
materials/library.rs # physical defaults and colours
shaders/viewport.wgsl # original viewport shader
fonts/<family>/ # unmodified fonts and OFL licences
fonts/{liberation-sans,biz-udp-gothic}/ # unmodified fonts and OFL licences
locales/{en,de,ja,ko}.json # shared offline catalogs
platforms/{macos,linux,windows}/ # bundle, freedesktop and WiX templates
platforms/macos/dmg/ # disk image window: layout, dmgbuild settings, background art
@@ -79,7 +80,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `io.tangencylabs.solidcraft` |
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies the Japanese font/licence to `fonts/` and the Noto Sans Arabic, KR, SC and TC fonts with their licences to `fonts/<family>/` (fetched when a drawing's text needs them), uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
## Code and provenance
+73
View File
@@ -0,0 +1,73 @@
# SolidCraft brand kit
Logo files for stickers, shirts, the web and social posts. Everything in `logo/` is drawn
from the app icon (`assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg`),
which is also the last frame of the loading animation (`assets/styles/web/loading-logo.svg`).
The SVGs are plain paths: no scripts, fonts, animation or external links, so any printer can use them.
## Files
| File | Use it for |
|---|---|
| `logo/solidcraft-mark.svg` | The mark in full colour. First choice for stickers, print and the web. |
| `logo/solidcraft-mark-mono-black.svg` | One-colour print on light material: screen-printed shirts, stamps, engraving, vinyl. |
| `logo/solidcraft-mark-mono-white.svg` | One-colour print on dark material, such as a white print on a dark shirt. |
| `logo/solidcraft-mark-512.png`, `-1024.png`, `-2048.png` | Transparent square PNGs for places that don't take SVG: avatars, social posts, slides, print shops that ask for a bitmap (send the 2048). |
| `logo/solidcraft-wordmark.svg` | The name on its own, outlined to paths. |
| `logo/solidcraft-lockup-horizontal.svg` | Mark and name side by side: headers, banners, shirt fronts, wide stickers. |
| `web/source/og.png` | The existing link preview card for the docs site. |
The gaps between the faces are part of the logo. In the colour and one-colour files they are
transparent, so the material shows through. For a die-cut sticker, cut around the outer shape
and let the backing colour (white is best) fill the gaps.
## Colours
The mark's five faces each use a two-stop gradient from the icon source:
| Face | From | To |
|---|---|---|
| Top | `#51adff` | `#2b83f1` |
| Left | `#258df5` | `#1461da` |
| Right | `#1b66cf` | `#124596` |
| Lower step (top) | `#459fff` | `#2379e7` |
| Upper step (inner side) | `#1d63c6` | `#15499e` |
Other colours, all already used in the project:
| Colour | Hex | Where it comes from |
|---|---|---|
| Wordmark blue | `#15499e` | loading logo `--word` |
| Interface accent | `#2678da` | light theme `accent` token (`assets/styles/ui/palettes.rs`) |
| Light background | `#e8ebf0` | web loading screen (`assets/styles/web/loading.css`) |
| Dark background | `#1f232b` | dark theme `file_background` token |
| Black / white | `#000000` / `#ffffff` | one-colour versions only |
If a printer can't do gradients, use the one-colour files, or fill every face with `#2678da`.
## Clear space and size
- Keep empty space around the mark of at least one fifth of its height on every side. Treat
the lockup the same way, measured from the mark.
- Smallest mark: 16 px tall on screen, 12 mm tall in print. Below that the gaps fill in.
- Smallest lockup: 120 px wide on screen, 40 mm wide in print.
## Don'ts
- Don't recolour it outside the colours above.
- Don't stretch, squash, rotate, skew or crop it. Scale it evenly.
- Don't add shadows, outlines, glows, bevels or other effects.
- Don't fill or close the gaps between the faces, or move the faces apart.
- Don't rearrange the lockup, retype the name in another font, or put the mark on a busy
background where it loses contrast.
## Name and logo
The SolidCraft code and these files are licensed MIT OR Apache-2.0 (see the [README](../../README.md#licence)),
so you may copy and change them. The name SolidCraft and this logo identify the SolidCraft
project. Use them to refer to the project, for example in an article, a talk, or a sticker for a
meetup. If you publish a fork or a modified build, give it its own name and logo so people
don't mistake it for SolidCraft.
The wordmark is set in IBM Plex Sans Bold (SIL Open Font License 1.1, the interface font in
`assets/fonts/ibm-plex-sans/`) and outlined, so you don't need the font to print it.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 2748.71 904.436" width="2749" height="904" role="img"><title>SolidCraft</title><defs><linearGradient id="sc1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d63c6"/><stop offset="1" stop-color="#15499e"/></linearGradient><linearGradient id="sc2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#459fff"/><stop offset="1" stop-color="#2379e7"/></linearGradient><linearGradient id="sc3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1b66cf"/><stop offset="1" stop-color="#124596"/></linearGradient><linearGradient id="sc4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#258df5"/><stop offset="1" stop-color="#1461da"/></linearGradient><linearGradient id="sc5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#51adff"/><stop offset="1" stop-color="#2b83f1"/></linearGradient></defs><g><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#sc1)"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#sc2)"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#sc3)"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#sc4)"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#sc5)"/></g><path fill="#15499e" transform="translate(1092.78 632.20) scale(0.36281)" d="M303 12Q212 12 147.5 -20Q83 -52 38 -101L138 -202Q173 -162 217.5 -142Q262 -122 313 -122Q369 -122 396 -145Q423 -168 423 -206Q423 -225 416.5 -240.5Q410 -256 392.5 -266.5Q375 -277 342 -281L273 -290Q200 -300 152.5 -326.5Q105 -353 82 -396.5Q59 -440 59 -498Q59 -561 90.5 -608.5Q122 -656 180.5 -683Q239 -710 321 -710Q400 -710 460 -684.5Q520 -659 563 -612L462 -510Q437 -539 401.5 -557.5Q366 -576 312 -576Q261 -576 236 -558Q211 -540 211 -508Q211 -484 219.5 -469Q228 -454 246.5 -446Q265 -438 294 -433L363 -422Q435 -411 482 -385Q529 -359 552 -317Q575 -275 575 -215Q575 -148 543.5 -97Q512 -46 451 -17Q390 12 303 12ZM926 12Q849 12 794 -21.5Q739 -55 709.5 -117Q680 -179 680 -263Q680 -348 709.5 -409Q739 -470 794 -503.5Q849 -537 926 -537Q1003 -537 1058 -503.5Q1113 -470 1142.5 -409Q1172 -348 1172 -263Q1172 -179 1142.5 -117Q1113 -55 1058 -21.5Q1003 12 926 12ZM926 -105Q970 -105 994 -132.5Q1018 -160 1018 -209V-316Q1018 -366 994 -393Q970 -420 926 -420Q883 -420 858.5 -393Q834 -366 834 -316V-209Q834 -160 858.5 -132.5Q883 -105 926 -105ZM1511 0H1444Q1368 0 1332.5 -37Q1297 -74 1297 -144V-740H1445V-115H1511ZM1694 -581Q1649 -581 1628.5 -602Q1608 -623 1608 -654V-676Q1608 -708 1628.5 -728.5Q1649 -749 1694 -749Q1739 -749 1759.5 -728.5Q1780 -708 1780 -676V-654Q1780 -623 1759.5 -602Q1739 -581 1694 -581ZM1620 0V-525H1768V0ZM2248 0V-88H2243Q2232 -45 2192.5 -16.5Q2153 12 2102 12Q2036 12 1991 -19.5Q1946 -51 1923 -112.5Q1900 -174 1900 -263Q1900 -352 1923 -413Q1946 -474 1991 -505.5Q2036 -537 2102 -537Q2153 -537 2192.5 -509Q2232 -481 2243 -437H2248V-740H2396V0ZM2151 -108Q2179 -108 2201 -117Q2223 -126 2235.5 -143.5Q2248 -161 2248 -185V-340Q2248 -364 2235.5 -381.5Q2223 -399 2201 -408Q2179 -417 2151 -417Q2109 -417 2081.5 -387.5Q2054 -358 2054 -308V-217Q2054 -168 2081.5 -138Q2109 -108 2151 -108ZM2844 12Q2750 12 2680.5 -28Q2611 -68 2573 -147Q2535 -226 2535 -344Q2535 -462 2573 -543.5Q2611 -625 2680.5 -667.5Q2750 -710 2844 -710Q2940 -710 3003.5 -670Q3067 -630 3105 -546L2974 -478Q2961 -522 2930.5 -548.5Q2900 -575 2844 -575Q2777 -575 2736.5 -531.5Q2696 -488 2696 -405V-293Q2696 -211 2736.5 -167Q2777 -123 2844 -123Q2900 -123 2933.5 -153.5Q2967 -184 2983 -227L3107 -155Q3068 -76 3004 -32Q2940 12 2844 12ZM3373 0H3225V-525H3373V-411H3378Q3384 -440 3400 -466Q3416 -492 3443.5 -508.5Q3471 -525 3512 -525H3538V-387H3501Q3458 -387 3429.5 -379.5Q3401 -372 3387 -355Q3373 -338 3373 -307ZM4105 0H4023Q3996 0 3972.5 -13.5Q3949 -27 3935 -53.5Q3921 -80 3921 -117V-130L3953 -92H3917Q3905 -41 3864 -14.5Q3823 12 3763 12Q3684 12 3642 -30.5Q3600 -73 3600 -141Q3600 -197 3627.5 -233Q3655 -269 3705.5 -287Q3756 -305 3825 -305H3908V-338Q3908 -376 3888 -398.5Q3868 -421 3821 -421Q3777 -421 3751 -402Q3725 -383 3708 -359L3620 -437Q3652 -484 3701 -510.5Q3750 -537 3832 -537Q3943 -537 3999.5 -487.5Q4056 -438 4056 -345V-115H4105ZM3908 -221H3836Q3793 -221 3771 -206Q3749 -191 3749 -162V-147Q3749 -119 3767 -105Q3785 -91 3818 -91Q3843 -91 3863 -98Q3883 -105 3895.5 -120Q3908 -135 3908 -159ZM4250 0V-410H4176V-525H4250V-587Q4250 -663 4290.5 -701.5Q4331 -740 4407 -740H4503V-625H4398V-525H4503V-410H4398V0ZM4877 0H4789Q4713 0 4673 -39Q4633 -78 4633 -153V-410H4559V-525H4596Q4626 -525 4637 -539.5Q4648 -554 4648 -581V-667H4781V-525H4885V-410H4781V-115H4877Z"/></svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><g fill="#000000"><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 612 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><g fill="#ffffff"><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z"/></g></svg>

After

Width:  |  Height:  |  Size: 612 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="116.406 53.364 791.188 904.436" width="791" height="904" role="img"><title>SolidCraft</title><defs><linearGradient id="sc1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d63c6"/><stop offset="1" stop-color="#15499e"/></linearGradient><linearGradient id="sc2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#459fff"/><stop offset="1" stop-color="#2379e7"/></linearGradient><linearGradient id="sc3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1b66cf"/><stop offset="1" stop-color="#124596"/></linearGradient><linearGradient id="sc4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#258df5"/><stop offset="1" stop-color="#1461da"/></linearGradient><linearGradient id="sc5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#51adff"/><stop offset="1" stop-color="#2b83f1"/></linearGradient></defs><g><path d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#sc1)"/><path d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#sc2)"/><path d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#sc3)"/><path d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#sc4)"/><path d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#sc5)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="38.0 -749.0 4847.0 761.0" width="4847" height="761" role="img"><title>SolidCraft</title><path fill="#15499e" d="M303 12Q212 12 147.5 -20Q83 -52 38 -101L138 -202Q173 -162 217.5 -142Q262 -122 313 -122Q369 -122 396 -145Q423 -168 423 -206Q423 -225 416.5 -240.5Q410 -256 392.5 -266.5Q375 -277 342 -281L273 -290Q200 -300 152.5 -326.5Q105 -353 82 -396.5Q59 -440 59 -498Q59 -561 90.5 -608.5Q122 -656 180.5 -683Q239 -710 321 -710Q400 -710 460 -684.5Q520 -659 563 -612L462 -510Q437 -539 401.5 -557.5Q366 -576 312 -576Q261 -576 236 -558Q211 -540 211 -508Q211 -484 219.5 -469Q228 -454 246.5 -446Q265 -438 294 -433L363 -422Q435 -411 482 -385Q529 -359 552 -317Q575 -275 575 -215Q575 -148 543.5 -97Q512 -46 451 -17Q390 12 303 12ZM926 12Q849 12 794 -21.5Q739 -55 709.5 -117Q680 -179 680 -263Q680 -348 709.5 -409Q739 -470 794 -503.5Q849 -537 926 -537Q1003 -537 1058 -503.5Q1113 -470 1142.5 -409Q1172 -348 1172 -263Q1172 -179 1142.5 -117Q1113 -55 1058 -21.5Q1003 12 926 12ZM926 -105Q970 -105 994 -132.5Q1018 -160 1018 -209V-316Q1018 -366 994 -393Q970 -420 926 -420Q883 -420 858.5 -393Q834 -366 834 -316V-209Q834 -160 858.5 -132.5Q883 -105 926 -105ZM1511 0H1444Q1368 0 1332.5 -37Q1297 -74 1297 -144V-740H1445V-115H1511ZM1694 -581Q1649 -581 1628.5 -602Q1608 -623 1608 -654V-676Q1608 -708 1628.5 -728.5Q1649 -749 1694 -749Q1739 -749 1759.5 -728.5Q1780 -708 1780 -676V-654Q1780 -623 1759.5 -602Q1739 -581 1694 -581ZM1620 0V-525H1768V0ZM2248 0V-88H2243Q2232 -45 2192.5 -16.5Q2153 12 2102 12Q2036 12 1991 -19.5Q1946 -51 1923 -112.5Q1900 -174 1900 -263Q1900 -352 1923 -413Q1946 -474 1991 -505.5Q2036 -537 2102 -537Q2153 -537 2192.5 -509Q2232 -481 2243 -437H2248V-740H2396V0ZM2151 -108Q2179 -108 2201 -117Q2223 -126 2235.5 -143.5Q2248 -161 2248 -185V-340Q2248 -364 2235.5 -381.5Q2223 -399 2201 -408Q2179 -417 2151 -417Q2109 -417 2081.5 -387.5Q2054 -358 2054 -308V-217Q2054 -168 2081.5 -138Q2109 -108 2151 -108ZM2844 12Q2750 12 2680.5 -28Q2611 -68 2573 -147Q2535 -226 2535 -344Q2535 -462 2573 -543.5Q2611 -625 2680.5 -667.5Q2750 -710 2844 -710Q2940 -710 3003.5 -670Q3067 -630 3105 -546L2974 -478Q2961 -522 2930.5 -548.5Q2900 -575 2844 -575Q2777 -575 2736.5 -531.5Q2696 -488 2696 -405V-293Q2696 -211 2736.5 -167Q2777 -123 2844 -123Q2900 -123 2933.5 -153.5Q2967 -184 2983 -227L3107 -155Q3068 -76 3004 -32Q2940 12 2844 12ZM3373 0H3225V-525H3373V-411H3378Q3384 -440 3400 -466Q3416 -492 3443.5 -508.5Q3471 -525 3512 -525H3538V-387H3501Q3458 -387 3429.5 -379.5Q3401 -372 3387 -355Q3373 -338 3373 -307ZM4105 0H4023Q3996 0 3972.5 -13.5Q3949 -27 3935 -53.5Q3921 -80 3921 -117V-130L3953 -92H3917Q3905 -41 3864 -14.5Q3823 12 3763 12Q3684 12 3642 -30.5Q3600 -73 3600 -141Q3600 -197 3627.5 -233Q3655 -269 3705.5 -287Q3756 -305 3825 -305H3908V-338Q3908 -376 3888 -398.5Q3868 -421 3821 -421Q3777 -421 3751 -402Q3725 -383 3708 -359L3620 -437Q3652 -484 3701 -510.5Q3750 -537 3832 -537Q3943 -537 3999.5 -487.5Q4056 -438 4056 -345V-115H4105ZM3908 -221H3836Q3793 -221 3771 -206Q3749 -191 3749 -162V-147Q3749 -119 3767 -105Q3785 -91 3818 -91Q3843 -91 3863 -98Q3883 -105 3895.5 -120Q3908 -135 3908 -159ZM4250 0V-410H4176V-525H4250V-587Q4250 -663 4290.5 -701.5Q4331 -740 4407 -740H4503V-625H4398V-525H4503V-410H4398V0ZM4877 0H4789Q4713 0 4673 -39Q4633 -78 4633 -153V-410H4559V-525H4596Q4626 -525 4637 -539.5Q4648 -554 4648 -581V-667H4781V-525H4885V-410H4781V-115H4877Z"/></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

-93
View File
@@ -1,93 +0,0 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
-92
View File
@@ -1,92 +0,0 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
-92
View File
@@ -1,92 +0,0 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
-92
View File
@@ -1,92 +0,0 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1 -1
View File
@@ -161,6 +161,7 @@ min_radius false
mirror false
motion_link false
move false
nav_settings false
new false
normal false
offset false
@@ -178,7 +179,6 @@ pattern_rect false
perforate false
perspective false
pipe false
placeholder true
plane false
plane_along_path false
plane_angle false
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Align drawing annotations</title>
<path d="M12 6L12 58" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M22.5 12L51.5 12A2.5 2.5 0 0 1 54 14.5L54 17.5A2.5 2.5 0 0 1 51.5 20L22.5 20A2.5 2.5 0 0 1 20 17.5L20 14.5A2.5 2.5 0 0 1 22.5 12Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M22.5 28L40.5 28A2.5 2.5 0 0 1 43 30.5L43 33.5A2.5 2.5 0 0 1 40.5 36L22.5 36A2.5 2.5 0 0 1 20 33.5L20 30.5A2.5 2.5 0 0 1 22.5 28Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M22.5 44L47.5 44A2.5 2.5 0 0 1 50 46.5L50 49.5A2.5 2.5 0 0 1 47.5 52L22.5 52A2.5 2.5 0 0 1 20 49.5L20 46.5A2.5 2.5 0 0 1 22.5 44Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 962 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Align drawing views</title>
<path d="M10.5 24L23.5 24A2.5 2.5 0 0 1 26 26.5L26 43.5A2.5 2.5 0 0 1 23.5 46L10.5 46A2.5 2.5 0 0 1 8 43.5L8 26.5A2.5 2.5 0 0 1 10.5 24Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M39.5 12L52.5 12A2.5 2.5 0 0 1 55 14.5L55 43.5A2.5 2.5 0 0 1 52.5 46L39.5 46A2.5 2.5 0 0 1 37 43.5L37 14.5A2.5 2.5 0 0 1 39.5 12Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M6 52L58 52" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M17 29L17 40M46 18L46 39" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 846 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Drawing balloon</title>
<path d="M9.5 37L29.5 37A2.5 2.5 0 0 1 32 39.5L32 52.5A2.5 2.5 0 0 1 29.5 55L9.5 55A2.5 2.5 0 0 1 7 52.5L7 39.5A2.5 2.5 0 0 1 9.5 37Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M20 45L35 30L40 25" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M15 50L19 37L28 45Z" fill="#236ead"/>
<circle cx="45" cy="17" r="12" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 651 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Base drawing view</title>
<path d="M9.5 10L54.5 10A2.5 2.5 0 0 1 57 12.5L57 51.5A2.5 2.5 0 0 1 54.5 54L9.5 54A2.5 2.5 0 0 1 7 51.5L7 12.5A2.5 2.5 0 0 1 9.5 10Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M17.5 19L42.5 19A2.5 2.5 0 0 1 45 21.5L45 39.5A2.5 2.5 0 0 1 42.5 42L17.5 42A2.5 2.5 0 0 1 15 39.5L15 21.5A2.5 2.5 0 0 1 17.5 19Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="30" cy="30.5" r="6" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M47 46L53 46M50 43L50 49" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 851 B

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Drawing centre mark</title>
<circle cx="32" cy="32" r="18" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 6L32 21M32 27L32 37M32 43L32 58M6 32L21 32M27 32L37 32M43 32L58 32" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 420 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Check drawing</title>
<path d="M14.5 7L49.5 7A2.5 2.5 0 0 1 52 9.5L52 54.5A2.5 2.5 0 0 1 49.5 57L14.5 57A2.5 2.5 0 0 1 12 54.5L12 9.5A2.5 2.5 0 0 1 14.5 7Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M21 19L42 19M21 29L35 29" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M27 44L37 53L57 30" fill="none" stroke="#ffffff" stroke-width="6.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M27 44L37 53L57 30" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 730 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Drawing datum</title>
<path d="M6 53L58 53" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M32 52L24 38L40 38Z" fill="#236ead"/>
<path d="M32 38L32 27" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M21.5 7L42.5 7A2.5 2.5 0 0 1 45 9.5L45 24.5A2.5 2.5 0 0 1 42.5 27L21.5 27A2.5 2.5 0 0 1 19 24.5L19 9.5A2.5 2.5 0 0 1 21.5 7Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 631 B

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Detail drawing view</title>
<path d="M9.5 11L30.5 11A2.5 2.5 0 0 1 33 13.5L33 45.5A2.5 2.5 0 0 1 30.5 48L9.5 48A2.5 2.5 0 0 1 7 45.5L7 13.5A2.5 2.5 0 0 1 9.5 11Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M13 18L23 18L23 33L29 33" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="23" cy="30" r="9" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M31 35L39 41" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="47" cy="46" r="12" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M40 39L46 39L46 48L53 48" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1000 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Drawing dimension</title>
<path d="M10 10L10 55M54 10L54 55" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10 14L54 14L54 36L10 36Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M20 49L44 49" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M11 49L23 43L23 55Z" fill="#236ead"/>
<path d="M53 49L41 55L41 43Z" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 598 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Edit drawing table</title>
<path d="M10.5 10L53.5 10A2.5 2.5 0 0 1 56 12.5L56 51.5A2.5 2.5 0 0 1 53.5 54L10.5 54A2.5 2.5 0 0 1 8 51.5L8 12.5A2.5 2.5 0 0 1 10.5 10Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10 12L54 12L54 23L10 23Z" fill="#b8c3cf"/>
<path d="M8 24L56 24M8 39L56 39M23 24L23 54" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M25 26L54 26L54 37L25 37Z" fill="#b9ddf8"/>
<path d="M32 32L47 32M42 27L47 32L42 37" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 741 B

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Hole callout</title>
<circle cx="18" cy="43" r="13" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="18" cy="43" r="6" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M27 35L39 22L57 22" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M23 40L27 28L34 35Z" fill="#236ead"/>
<circle cx="44" cy="10" r="4.5" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M39 16L49 4" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 794 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Insert drawing image</title>
<path d="M9.5 11L54.5 11A2.5 2.5 0 0 1 57 13.5L57 50.5A2.5 2.5 0 0 1 54.5 53L9.5 53A2.5 2.5 0 0 1 7 50.5L7 13.5A2.5 2.5 0 0 1 9.5 11Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="43" cy="23" r="5" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10 45L24 27L36 41L42 34L54 47Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 622 B

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Drawing leader</title>
<path d="M8.5 34L33.5 34A2.5 2.5 0 0 1 36 36.5L36 51.5A2.5 2.5 0 0 1 33.5 54L8.5 54A2.5 2.5 0 0 1 6 51.5L6 36.5A2.5 2.5 0 0 1 8.5 34Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M18 45L39 18L56 18" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M15 49L19 35L26 41Z" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 516 B

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Drawing parts list</title>
<path d="M10.5 10L53.5 10A2.5 2.5 0 0 1 56 12.5L56 51.5A2.5 2.5 0 0 1 53.5 54L10.5 54A2.5 2.5 0 0 1 8 51.5L8 12.5A2.5 2.5 0 0 1 10.5 10Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10 12L54 12L54 23L10 23Z" fill="#b8c3cf"/>
<path d="M8 24L56 24M8 39L56 39M23 24L23 54" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="15.5" cy="31.5" r="3.5" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="15.5" cy="46.5" r="3.5" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M30 31L48 31M30 46L48 46" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 958 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Print drawing</title>
<path d="M18.5 7L45.5 7A2.5 2.5 0 0 1 48 9.5L48 29.5A2.5 2.5 0 0 1 45.5 32L18.5 32A2.5 2.5 0 0 1 16 29.5L16 9.5A2.5 2.5 0 0 1 18.5 7Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M8.5 24L55.5 24A2.5 2.5 0 0 1 58 26.5L58 49.5A2.5 2.5 0 0 1 55.5 52L8.5 52A2.5 2.5 0 0 1 6 49.5L6 26.5A2.5 2.5 0 0 1 8.5 24Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="48" cy="32" r="2.5" fill="#236ead" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M18.5 37L45.5 37A2.5 2.5 0 0 1 48 39.5L48 55.5A2.5 2.5 0 0 1 45.5 58L18.5 58A2.5 2.5 0 0 1 16 55.5L16 39.5A2.5 2.5 0 0 1 18.5 37Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M23 44L41 44M23 51L36 51" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Projected drawing view</title>
<path d="M9.5 33L31.5 33A2.5 2.5 0 0 1 34 35.5L34 52.5A2.5 2.5 0 0 1 31.5 55L9.5 55A2.5 2.5 0 0 1 7 52.5L7 35.5A2.5 2.5 0 0 1 9.5 33Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="20.5" cy="44" r="5" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M9.5 8L31.5 8A2.5 2.5 0 0 1 34 10.5L34 19.5A2.5 2.5 0 0 1 31.5 22L9.5 22A2.5 2.5 0 0 1 7 19.5L7 10.5A2.5 2.5 0 0 1 9.5 8Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M9 25L9 30M32 25L32 30M38 44L54 44" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="3 5"/>
<path d="M44 28L44 13" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M44 7L48 14L40 14Z" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Browse drawing resources</title>
<path d="M15 7L51 7A3 3 0 0 1 54 10L54 48A3 3 0 0 1 51 51L15 51A3 3 0 0 1 12 48L12 10A3 3 0 0 1 15 7Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10 12L46 12A3 3 0 0 1 49 15L49 53A3 3 0 0 1 46 56L10 56A3 3 0 0 1 7 53L7 15A3 3 0 0 1 10 12Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M14 20L42 20L42 47L14 47Z" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M14 38L42 38M28 38L28 47" fill="none" stroke="#6f879c" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M22 27L34 27L34 36L22 36Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 933 B

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Add drawing revision</title>
<path d="M14.5 7L49.5 7A2.5 2.5 0 0 1 52 9.5L52 54.5A2.5 2.5 0 0 1 49.5 57L14.5 57A2.5 2.5 0 0 1 12 54.5L12 9.5A2.5 2.5 0 0 1 14.5 7Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M20 19L43 19M20 29L37 29" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M43 33L58 57L28 57Z" fill="#ffffff" stroke="#ffffff" stroke-width="4.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M43 33L58 57L28 57Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 745 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Drawing revision table</title>
<path d="M10.5 10L53.5 10A2.5 2.5 0 0 1 56 12.5L56 51.5A2.5 2.5 0 0 1 53.5 54L10.5 54A2.5 2.5 0 0 1 8 51.5L8 12.5A2.5 2.5 0 0 1 10.5 10Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10 12L54 12L54 23L10 23Z" fill="#b8c3cf"/>
<path d="M8 24L56 24M8 39L56 39M23 24L23 54" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M15 27L10 35L20 35ZM15 42L10 50L20 50Z" fill="#236ead"/>
<path d="M31 31L49 31M31 46L49 46" fill="none" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 752 B

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Section drawing view</title>
<path d="M8 15L56 15L56 50L37 50L37 27L27 27L27 50L8 50Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M10 38L26 22M10 25L20 15M10 50L24 36M39 35L54 20M39 48L54 33M49 50L56 43" fill="none" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 450 B

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Space drawing annotations</title>
<path d="M28.5 9L53.5 9A2.5 2.5 0 0 1 56 11.5L56 14.5A2.5 2.5 0 0 1 53.5 17L28.5 17A2.5 2.5 0 0 1 26 14.5L26 11.5A2.5 2.5 0 0 1 28.5 9Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M28.5 28L53.5 28A2.5 2.5 0 0 1 56 30.5L56 33.5A2.5 2.5 0 0 1 53.5 36L28.5 36A2.5 2.5 0 0 1 26 33.5L26 30.5A2.5 2.5 0 0 1 28.5 28Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M28.5 47L53.5 47A2.5 2.5 0 0 1 56 49.5L56 52.5A2.5 2.5 0 0 1 53.5 55L28.5 55A2.5 2.5 0 0 1 26 52.5L26 49.5A2.5 2.5 0 0 1 28.5 47Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M15 19L15 25M15 38L15 44" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M15 14L10 21L20 21ZM15 30L10 23L20 23ZM15 33L10 40L20 40ZM15 49L10 42L20 42Z" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Update drawing</title>
<path d="M14.5 7L49.5 7A2.5 2.5 0 0 1 52 9.5L52 54.5A2.5 2.5 0 0 1 49.5 57L14.5 57A2.5 2.5 0 0 1 12 54.5L12 9.5A2.5 2.5 0 0 1 14.5 7Z" fill="#e2e8ef" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M22.5 16L41.5 16A2.5 2.5 0 0 1 44 18.5L44 28.5A2.5 2.5 0 0 1 41.5 31L22.5 31A2.5 2.5 0 0 1 20 28.5L20 18.5A2.5 2.5 0 0 1 22.5 16Z" fill="#b8c3cf" stroke="#34465b" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M38 35A11 11 0 1 0 45 45" fill="none" stroke="#ffffff" stroke-width="6.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M38 35A11 11 0 1 0 45 45" fill="none" stroke="#236ead" stroke-width="4.25" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M44 40L32 37L40 28Z" fill="#236ead"/>
</svg>

Before

Width:  |  Height:  |  Size: 900 B

+48 -4
View File
@@ -85,6 +85,7 @@
"Bottom Right": "أسفل اليمين",
"Boundary": "الحد",
"Bounding box": "الصندوق المحيط",
"Bridge": "جسر",
"CHECK": "تحقق",
"COMPONENTS": "المكوّنات",
"CONFIGURATIONS": "التكوينات",
@@ -1296,6 +1297,7 @@
"cmd.timeline_stop": "إيقاف السجل",
"cmd.timeline_suppress": "تعطيل الميزة",
"cmd.ui.assistant": "إظهار المساعد أو إخفاؤه",
"cmd.view.navigator": "متصفح العرض المخصص",
"cmd.view.render_browser": "متصفح التصيير",
"cmd.view.render_dock": "نقل لوحة التصيير",
"cmd.view.render_focus": "عرض تركيز التصيير",
@@ -1445,6 +1447,9 @@
"each rail's curves must be in one sketch": "يجب أن تكون منحنيات كل دليل في رسم واحد",
"feature computation ended without a result": "انتهى حساب الميزة دون نتيجة",
"file.open.drawing_filter": "التصاميم وملفات CAD والشبكات والرسومات",
"file.open.portal_fallback.no_portal_service": "منتقي الملفات الذي فُتح ليس تابعًا لسطح المكتب: خدمة منتقي الملفات في سطح المكتب غير متاحة.",
"file.open.portal_fallback.no_session_bus": "منتقي الملفات الذي فُتح ليس تابعًا لسطح المكتب: لا يوجد ناقل جلسات D-Bus.",
"file.open.portal_fallback.xwayland": "منتقي الملفات الذي فُتح ليس تابعًا لسطح المكتب: في XWayland ترفض البوابة النافذة الأصلية.",
"file_load.built": "بُني حتى الآن: {built} من {total} ميزة.",
"file_load.converting": "تحويل الجسم الصلب {done} من {total}",
"file_load.inserting": "إدراج الهندسة",
@@ -1605,7 +1610,7 @@
"help.parts.resize": "يغيّر مقاس قطعة قياسية مدرجة أو طولها ويعيد بناءها، وتبقيها مفاصلها مثبتة في مكانها. استخدمه عندما يتبين أن برغيًا أطول من اللازم أو أن ثقبًا يحتاج إلى مقاس آخر.",
"help.plastic.assign_rule": "يعيّن قاعدة بلاستيك لأجسام، فتأخذ ميزاتها البلاستيكية ميلها وخلوصها منها كقيم افتراضية. استخدمه لإعداد قطعة مقولبة مرة واحدة لكل ميزاتها.",
"help.plastic.boss": "يضيف نتوءًا لمسمار على وجه: عمودًا مع ثقب اختياري وميل وتقويس عند الجذر وأضلاع تقوية. استخدمه لنقاط البراغي في الهياكل المقولبة أو المطبوعة.",
"help.plastic.lip": "يضيف شفة أو أخدودًا مطابقًا على طول حافة جسم مفرَّغ. استخدمه ليتمركز نصفا الهيكل أحدهما على الآخر ويُغلقا بإحكام.",
"help.plastic.lip": "يضيف شفة أو أخدودًا مطابقًا على طول إطار جسم مفرَّغ (فرّغ الجسم بفتح أحد وجوهه أولًا، ثم اختر الإطار المحيط بتلك الفتحة). استخدمه ليتمركز نصفا الهيكل أحدهما على الآخر ويُغلقا بإحكام.",
"help.plastic.manage_rules": "ينشئ قواعد البلاستيك ويعدّلها: المادة وسماكة الجدار وأنصاف الأقطار والخلوص والميل. استخدمها للحفاظ على اتساق ميزات القطعة المقولبة.",
"help.plastic.rest": "يضيف مسندًا على وجه: وسادة مستديرة أو مستطيلة، مصمتة أو مجوفة، مع ميل. استخدمه كدعامة أو مُباعِد في هيكل مقولب.",
"help.plastic.snap_fit": "يضيف خطاف وصلة كبس على وجه، بطوله وسماكته ونتوء التثبيت. استخدمه لتثبيت القطع معًا بلا براغي.",
@@ -2337,8 +2342,8 @@
"prefs.dialogs_edge": "عند حافة العرض",
"prefs.dialogs_left_column": "في العمود الأيسر",
"prefs.dialogs_note": "مكان فتح حوارات الأوامر: مثبتة عند حافة العرض، أو في العمود الأيسر مكان المستعرض والسجل، اللذين يعودان كما كانا عند إغلاق الحوار.",
"prefs.dimension_step_angle": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الزاوية",
"prefs.dimension_step_length": "ما تضيفه أسهم النافذة وأعلى/أسفل والعجلة إلى الطول",
"prefs.dimension_step_angle": "ما تضيفه أسهم النافذة والسهمان لأعلى ولأسفل والعجلة إلى الزاوية",
"prefs.dimension_step_length": "ما تضيفه أسهم النافذة والسهمان لأعلى ولأسفل والعجلة إلى الطول",
"prefs.dimension_steps": "خطوات النافذة",
"prefs.dimension_values": "قيم الأبعاد",
"prefs.dimension_values_note": "مكان كتابة قيمة البعد. للنافذة أسهم تغيّرها بخطوات؛ تضع أداة البعد النص أولًا.",
@@ -2554,6 +2559,8 @@
"prefs.section_hatch_by_part": "حسب الجزء",
"prefs.section_hatch_one": "نمط واحد",
"prefs.select": "تحديد",
"prefs.select_hidden_edges": "تحديد الحواف المخفية في العرض المظلل مع الحواف المخفية",
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأسطح عند تفعيل العرض المظلل مع الحواف المخفية، بما في ذلك استخدام أداة Fillet. يحافظ إيقافه على سلوك التحديد في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
"prefs.select_one_item": "تحديد عنصر واحد",
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
@@ -2578,6 +2585,8 @@
"prefs.sketch.click_selects": "النقر يحدد",
"prefs.sketch.conflict_colour": "لون التعارض",
"prefs.sketch.drawing": "الرسم",
"prefs.sketch.ellipse_axes": "رسم القطوع الناقصة مع محاورها",
"prefs.sketch.ellipse_axes_note": "المحوران الأكبر والأصغر كخطوط مساعدة، لتحديد أبعاد أنصاف المحاور والمحاور. عند الإيقاف: القطع الناقص وحده، ويعطي D عليه نصف قطره الأصغر أينما سُحبت القيمة، مرسومًا ومُعلَّمًا بـ R كما في السابق.",
"prefs.sketch.exit_keep": "إبقاء",
"prefs.sketch.exit_previous": "العودة إلى العرض السابق",
"prefs.sketch.exit_unless_rotated": "إلا إذا تم التدوير",
@@ -4117,7 +4126,7 @@
"settings.display_quality_note": "المستويات الأدق ترسم الأوجه والحواف المنحنية بنعومة أكبر وتستغرق وقتًا أطول لإنشاء الشبكة. يتغير العرض فقط، لا التصميم ولا الملفات المصدّرة.",
"settings.double_click_components": "النقر المزدوج على مكوّن لتنشيطه",
"settings.frame_rate_details_note": "يضيف رسمًا لأزمنة الإطارات الأخيرة مع المثلثات المرسومة فوقه، ومدرّجًا تكراريًا لأزمنة الإطارات، وما رسمته وحدة GPU. انقر معدل الإطارات في العرض لإظهارها أو إخفائها.",
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض. أثناء تفعيله يُعاد رسم العرض باستمرار.",
"settings.frame_rate_note": "يعرض الإطارات في الثانية في الزاوية اليمنى السفلية من العرض.",
"settings.insert_lists_open_designs": "إدراج تصميم يسرد التصاميم المفتوحة أولًا",
"settings.measure_axis_distances": "مسافات المحاور في القياس",
"settings.measure_axis_distances_help": "إظهار مركّبات الأبعاد على محاور العالم بجانب المسافة الكلية أثناء فتح أداة القياس.",
@@ -4168,6 +4177,32 @@
"sketch.dof_left": "تبقّت {count} درجات حرية",
"sketch.dof_left_one": "تبقّت {count} درجة حرية",
"sketch.draw_curves": "الرسم كـ {linetype}",
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
"sketch.error.nothing_to_break": "nothing to break here",
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
"sketch.hint.break": "Break: click a curve where it should split",
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
"sketch.hint.fallback": "Click in the sketch",
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
"sketch.hint.open_done": "Open done",
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
"sketch.hint.slot": "Slot: click both ends, then the width",
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
"sketch.make_selection": "تحويل التحديد إلى {linetype}",
"sketch.open_ends": "مفتوحة {count}",
"sketch.status_conflict": "تعارض القيود",
@@ -5017,6 +5052,7 @@
"ui.heading_construct": "إنشاء مرجعي",
"ui.heading_create": "إنشاء",
"ui.heading_create_sketch": "إنشاء رسم",
"ui.heading_custom_view_navigator": "متصفح العرض المخصص",
"ui.heading_cylinder": "أسطوانة",
"ui.heading_design": "التصميم",
"ui.heading_draft": "ميل السحب",
@@ -5612,6 +5648,14 @@
"values must be numbers or expressions": "يجب أن تكون القيم أعدادًا أو تعابير",
"view.auto_ortho_faces": "منظوري أثناء التدوير، ومتعامد على الأوجه",
"view.render_inspector": "مفتش التصيير",
"view_gizmo.arrows": "أسهم التدوير",
"view_gizmo.home": "زر العرض الرئيسي",
"view_gizmo.no_such_body": "لا يوجد في هذا التصميم جسم بهذا الاسم.",
"view_gizmo.note": "اختر جسمًا في العرض. يُحفظ مع تفضيلاتك وملفات تعريف الإعدادات، ويظهر في كل تصميم. انقر أحد أوجهه للنظر إليه مباشرة.",
"view_gizmo.texture": "الخامة",
"view_gizmo.themed": "حسب السمة",
"view_gizmo.too_detailed": "مفصّل أكثر مما يناسب متصفح العرض (أكثر من 10,000 مثلث)",
"view_gizmo.too_thin": "طويل ورفيع أكثر مما يناسب متصفح العرض: يجب ألا يقل أرفع جانب عن خُمس أطول جانب",
"web.files.insert_drawings_hint": "ملفات CAD أو شبكات أو DXF أو SVG إلى التصميم المفتوح",
"web.files.insert_stored_drawings_hint": "إلى التصميم المفتوح (ملفات CAD، شبكات، DXF، SVG)",
"web.open_full": "فتح في SolidCraft",
+17 -7
View File
@@ -833,8 +833,6 @@
"cmd.appearance_pick_up": "Aussehen aufnehmen",
"cmd.appearance_replace": "Erscheinungsbild ersetzen",
"cmd.assembly_bom": "Stückliste",
"cmd.assembly_bom_item_set": "Positionsnummer ändern",
"cmd.assembly_bom_renumber": "Positionen neu nummerieren",
"cmd.assembly_mass_properties": "Masseeigenschaften",
"cmd.body.print_face": "Auflagefläche",
"cmd.body_rename": "Körper umbenennen",
@@ -1026,7 +1024,6 @@
"cmd.motion_trace_edit": "Bewegungsbahn bearbeiten",
"cmd.motion_trace_keep": "Bewegungsbahn behalten",
"cmd.occurrence_align": "Instanz ausrichten",
"cmd.occurrence_bom_type": "Stücklistentyp",
"cmd.occurrence_copy": "Einfügen (Instanz)",
"cmd.occurrence_drag": "Instanz ziehen",
"cmd.occurrence_ground": "Festsetzen",
@@ -1294,6 +1291,7 @@
"cmd.timeline_stop": "Verlauf anhalten",
"cmd.timeline_suppress": "Merkmal unterdrücken",
"cmd.ui.assistant": "Assistenten ein-/ausblenden",
"cmd.view.navigator": "Eigener Ansichtsnavigator",
"cmd.view.render_browser": "Render-Browser",
"cmd.view.render_dock": "Render-Panel verschieben",
"cmd.view.render_focus": "Render-Fokusansicht",
@@ -1442,6 +1440,9 @@
"each rail's curves must be in one sketch": "die Kurven jeder Führungskurve müssen in derselben Skizze liegen",
"feature computation ended without a result": "die Berechnung des Merkmals wurde ohne Ergebnis beendet",
"file.open.drawing_filter": "Designs, CAD-Dateien, Netze und Zeichnungen",
"file.open.portal_fallback.no_portal_service": "Die geöffnete Dateiauswahl ist nicht die des Desktops: Der Dateiauswahl-Dienst des Desktops ist nicht verfügbar.",
"file.open.portal_fallback.no_session_bus": "Die geöffnete Dateiauswahl ist nicht die des Desktops: Es gibt keinen D-Bus-Sitzungsbus.",
"file.open.portal_fallback.xwayland": "Die geöffnete Dateiauswahl ist nicht die des Desktops: Unter XWayland lehnt das Portal das Elternfenster ab.",
"file_load.built": "Bisher erstellt: {built} von {total} Merkmalen.",
"file_load.converting": "Volumenkörper {done} von {total} wird konvertiert",
"file_load.inserting": "Geometrie wird eingefügt",
@@ -2330,7 +2331,6 @@
"prefs.dimension_values_view": "In der Ansicht",
"prefs.dimension_values_window": "In einem Fenster",
"prefs.display": "Anzeige",
"prefs.drawings": "Zeichnungen",
"prefs.edges": "Kanten und Schattierung",
"prefs.extend": "Erweitern",
"prefs.extra_navigation": "Mit Strg und gedrückter linker Maustaste auf einer freien Fläche drehen. Leertaste gedrückt halten, um die Ansicht zu verschieben.",
@@ -2535,6 +2535,8 @@
"prefs.search": "Einstellungen suchen",
"prefs.search_empty": "Keine passenden Einstellungen",
"prefs.select": "Auswählen",
"prefs.select_hidden_edges": "Verdeckte Kanten in der schattierten Ansicht mit verdeckten Kanten auswählen",
"prefs.select_hidden_edges_help": "Kanten durch Flächen auswählen, wenn die schattierte Ansicht mit verdeckten Kanten aktiv ist, auch zum Verrunden. Aus behält das Auswahlverhalten von v0.30.1 bei. Ausgeblendete Körper bleiben ausgeschlossen.",
"prefs.select_one_item": "Ein Element auswählen",
"prefs.select_other_hidden": "Verborgene Geometrie mit „Andere auswählen“ auswählen",
"prefs.select_other_hidden_help": "Ein: Verborgene Körper, Skizzen und verdeckte Kanten nahe dem Zeiger einbeziehen. Aus: Die ursprüngliche Liste sichtbarer Geometrie direkt unter dem Zeiger verwenden.",
@@ -2559,6 +2561,8 @@
"prefs.sketch.click_selects": "Klick wählt",
"prefs.sketch.conflict_colour": "Konfliktfarbe",
"prefs.sketch.drawing": "Zeichnen",
"prefs.sketch.ellipse_axes": "Ellipsen mit ihren Achsen zeichnen",
"prefs.sketch.ellipse_axes_note": "Haupt- und Nebenachse als Konstruktionslinien, damit Halbachsen und Achsen bemaßt werden können. Aus: nur die Ellipse, und D darauf ergibt ihren Nebenradius, wohin der Wert auch gezogen wird, gezeichnet und mit R beschriftet wie bisher.",
"prefs.sketch.exit_keep": "Beibehalten",
"prefs.sketch.exit_previous": "Zur Ansicht davor zurückkehren",
"prefs.sketch.exit_unless_rotated": "Außer bei Drehung",
@@ -4957,7 +4961,6 @@
"ui.fine": "Fein",
"ui.finest": "Sehr fein",
"ui.finish_sketch": "Skizze beenden",
"ui.first_angle": "Erster Winkel",
"ui.fit": "Einpassen",
"ui.fix_unfix": "Fixieren/Lösen",
"ui.flat": "Flach",
@@ -4994,6 +4997,7 @@
"ui.heading_construct": "KONSTRUKTION",
"ui.heading_create": "ERSTELLEN",
"ui.heading_create_sketch": "SKIZZE ERSTELLEN",
"ui.heading_custom_view_navigator": "EIGENER ANSICHTSNAVIGATOR",
"ui.heading_cylinder": "ZYLINDER",
"ui.heading_design": "DESIGN",
"ui.heading_draft": "ABZIEHEN",
@@ -5258,7 +5262,6 @@
"ui.profiles": "Profile",
"ui.project": "Projizieren",
"ui.project_face_edges_sketch": "Flächenkanten in neue Skizzen projizieren",
"ui.projection_of_new_drawings": "Projektion neuer Zeichnungen",
"ui.properties": "Eigenschaften",
"ui.properties.copy_matrix": "Matrix kopieren",
"ui.properties.first_moments": "Statische Momente",
@@ -5441,7 +5444,6 @@
"ui.text": "Text",
"ui.theme": "Design",
"ui.thickness": "Dicke",
"ui.third_angle": "Dritter Winkel",
"ui.thread": "Gewinde",
"ui.tie_ends": "Verknüpfen",
"ui.tie_ends_tip": "Alle unverknüpften Enden mit Abhängigkeiten versehen",
@@ -5584,6 +5586,14 @@
"values must be numbers or expressions": "Werte müssen Zahlen oder Ausdrücke sein",
"view.auto_ortho_faces": "Perspektivisch beim Drehen, orthografisch auf Flächen",
"view.render_inspector": "Render-Inspektor",
"view_gizmo.arrows": "Drehpfeile",
"view_gizmo.home": "Schaltfläche „Ausgangsansicht“",
"view_gizmo.no_such_body": "Diese Konstruktion hat keinen Körper mit diesem Namen.",
"view_gizmo.note": "Wählen Sie einen Körper in der Ansicht. Er wird mit Ihren Einstellungen und Einstellungsprofilen gespeichert und in jeder Konstruktion angezeigt. Klicken Sie auf eine seiner Flächen, um direkt darauf zu blicken.",
"view_gizmo.texture": "Textur",
"view_gizmo.themed": "Design",
"view_gizmo.too_detailed": "Zu detailliert für den Ansichtsnavigator (über 10.000 Dreiecke)",
"view_gizmo.too_thin": "Zu lang und dünn für den Ansichtsnavigator: Die dünnste Seite muss mindestens ein Fünftel der längsten sein",
"web.files.insert_drawings_hint": "CAD-Dateien, Netze, DXF oder SVG in das geöffnete Design",
"web.files.insert_stored_drawings_hint": "In das geöffnete Design (CAD-Dateien, Netze, DXF, SVG)",
"web.open_full": "In SolidCraft öffnen",
+5
View File
@@ -87,6 +87,7 @@
"prefs.max_file_size_web": "The web app opens STEP and import files up to 512 MB and designs up to 256 MB: a browser tab can't hold larger files. The desktop app can raise these limits.",
"prefs.megabytes": "MB",
"prefs.network_locations_note": "For organisations that keep designs on file servers. Off: only servers you allow.",
"prefs.select_hidden_edges_help": "Select edges through faces when Shaded with hidden edges is active, including for Fillet. Off keeps v0.30.1 selection behaviour. Hidden bodies stay unselectable.",
"prefs.sketch.center_arc": "Centre-point arc follows the cursor",
"prefs.sketch.conflict_colour": "Conflict colour",
"prefs.sketch.tracking_guides": "Centre and midpoint guides",
@@ -235,6 +236,10 @@
"render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimetres.",
"render.studio_center": "Centre X, Y, Z (mm)",
"render.studio_hdr_help": "Radiance .hdr, 2:1 panorama, up to 2048 × 1024. EXR is unavailable. The colour assumption applies only when primaries are unspecified.",
"sketch.hint.arc.center_point": "Arc: click centre and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.circle.center": "Circle: click the centre, then a point on the circle",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to centre the second on the first",
"sketch.hint.rectangle.center": "Centre rectangle: click the centre, then a corner",
"sketch_text.align_center": "Centre",
"ui.3d_mouse_kind_cap": "Return to Centre",
"ui.3d_mouse_looks_like_cap": "The reading stays put while you hold it, the way a Return to Centre device does.",
+52 -45
View File
@@ -85,6 +85,7 @@
"Bottom Right": "Bottom Right",
"Boundary": "Boundary",
"Bounding box": "Bounding box",
"Bridge": "Bridge",
"CHECK": "CHECK",
"COMPONENTS": "COMPONENTS",
"CONFIGURATIONS": "CONFIGURATIONS",
@@ -833,8 +834,6 @@
"cmd.appearance_pick_up": "Pick Up Appearance",
"cmd.appearance_replace": "Replace Appearance",
"cmd.assembly_bom": "Bill of Materials",
"cmd.assembly_bom_item_set": "Change Item Number",
"cmd.assembly_bom_renumber": "Renumber Items",
"cmd.assembly_mass_properties": "Mass Properties",
"cmd.body.print_face": "Print Face",
"cmd.body_rename": "Rename Body",
@@ -934,36 +933,6 @@
"cmd.document_metadata_set": "Change Design Properties",
"cmd.document_revision": "Design Revision",
"cmd.document_units": "Change Design Units",
"cmd.drawing_balloon_add": "Add Balloon",
"cmd.drawing_balloon_edit": "Edit Balloon",
"cmd.drawing_balloon_inspect": "Inspect Balloon",
"cmd.drawing_close": "Close Drawing",
"cmd.drawing_create": "Create Drawing",
"cmd.drawing_delete": "Delete Drawing",
"cmd.drawing_dimension_add": "Add Drawing Dimension",
"cmd.drawing_dimension_edit": "Edit Drawing Dimension",
"cmd.drawing_dimension_inspect": "Inspect Drawing Dimension",
"cmd.drawing_export": "Export Sheet",
"cmd.drawing_inspect": "Inspect Drawing",
"cmd.drawing_list": "List Drawings",
"cmd.drawing_open": "Open Drawing",
"cmd.drawing_properties": "Drawing Properties",
"cmd.drawing_properties_remove": "Remove Drawing Property",
"cmd.drawing_properties_set": "Change Drawing Properties",
"cmd.drawing_revision_add": "Add Revision",
"cmd.drawing_revision_delete": "Delete Revision",
"cmd.drawing_revision_edit": "Edit Revision",
"cmd.drawing_scene": "Draw Sheet",
"cmd.drawing_set": "Drawing Settings",
"cmd.drawing_sheet_add": "Add Sheet",
"cmd.drawing_sheet_delete": "Delete Sheet",
"cmd.drawing_sheet_reorder": "Reorder Sheets",
"cmd.drawing_sheet_set": "Sheet Settings",
"cmd.drawing_view_copy_to_sheet": "Copy View to Sheet",
"cmd.drawing_view_edit": "Edit Drawing View",
"cmd.drawing_view_inspect": "Inspect Drawing View",
"cmd.drawing_view_move_to_sheet": "Move View to Sheet",
"cmd.drawing_view_snapshot": "Drawing View Snapshot",
"cmd.edit_checkpoint": "Checkpoint",
"cmd.edit_repeat": "Repeat Last Command",
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
@@ -1061,7 +1030,6 @@
"cmd.motion_trace_edit": "Edit Motion Trace",
"cmd.motion_trace_keep": "Keep Motion Trace",
"cmd.occurrence_align": "Align Occurrence",
"cmd.occurrence_bom_type": "Bill of Materials Type",
"cmd.occurrence_copy": "Paste (Instance)",
"cmd.occurrence_drag": "Drag Occurrence",
"cmd.occurrence_ground": "Ground",
@@ -1205,7 +1173,7 @@
"cmd.sketch_arc_tangent": "Tangent Arc",
"cmd.sketch_arc_three_point": "3-Point Arc",
"cmd.sketch_auto_constrain": "AutoConstrain",
"cmd.sketch_auto_constrain_from_datum": "AutoConstrain from datum",
"cmd.sketch_auto_constrain_from_datum": "AutoConstrain from Datum",
"cmd.sketch_auto_project": "Auto-Project Edges on Reference",
"cmd.sketch_blend_curve": "Blend Curve",
"cmd.sketch_cancel": "Cancel Sketch",
@@ -1219,7 +1187,7 @@
"cmd.sketch_circle_two_point": "2-Point Circle",
"cmd.sketch_circle_two_tangent": "2-Tangent Circle",
"cmd.sketch_constrainer": "Sketch Constrainer",
"cmd.sketch_constraint_fix": "Fix/UnFix",
"cmd.sketch_constraint_fix": "Fix/Unfix",
"cmd.sketch_constraint_midpoint": "MidPoint",
"cmd.sketch_construction": "Normal/Construction",
"cmd.sketch_copy": "Copy Sketch Entities",
@@ -1329,6 +1297,7 @@
"cmd.timeline_stop": "Stop History",
"cmd.timeline_suppress": "Suppress Feature",
"cmd.ui.assistant": "Toggle Assistant",
"cmd.view.navigator": "Custom View Navigator",
"cmd.view.render_browser": "Render Browser",
"cmd.view.render_dock": "Move Render Dock",
"cmd.view.render_focus": "Render Focus View",
@@ -1344,13 +1313,13 @@
"cmd.view_front": "Front View",
"cmd.view_graphics_preset": "Graphics Preset",
"cmd.view_grid": "Grid",
"cmd.view_ground_shadow": "Ground shadow",
"cmd.view_home": "Home view",
"cmd.view_ground_shadow": "Ground Shadow",
"cmd.view_home": "Home View",
"cmd.view_isometric": "Isometric View",
"cmd.view_left": "Left View",
"cmd.view_list": "List Named Views",
"cmd.view_navigate": "Navigate View",
"cmd.view_navigation_bar": "Navigation bar",
"cmd.view_navigation_bar": "Navigation Bar",
"cmd.view_next_tab": "Next Tab",
"cmd.view_normal_to": "Normal to Selection",
"cmd.view_previous_tab": "Previous Tab",
@@ -1359,10 +1328,10 @@
"cmd.view_right": "Right View",
"cmd.view_save": "New Named View",
"cmd.view_shots_strip": "Shots Strip",
"cmd.view_spin": "Continuous orbit",
"cmd.view_tool": "Navigation tool",
"cmd.view_spin": "Continuous Orbit",
"cmd.view_tool": "Navigation Tool",
"cmd.view_top": "Top View",
"cmd.view_visual_style": "Visual style",
"cmd.view_visual_style": "Visual Style",
"color.blue": "Blue",
"color.copy": "Click to copy color values",
"color.edit": "Click to edit color",
@@ -1478,6 +1447,9 @@
"each rail's curves must be in one sketch": "each rail's curves must be in one sketch",
"feature computation ended without a result": "feature computation ended without a result",
"file.open.drawing_filter": "Designs, CAD files, meshes and drawings",
"file.open.portal_fallback.no_portal_service": "The file chooser that opened is not the desktop's own: the desktop portal service is not available.",
"file.open.portal_fallback.no_session_bus": "The file chooser that opened is not the desktop's own: there is no D-Bus session bus.",
"file.open.portal_fallback.xwayland": "The file chooser that opened is not the desktop's own: under XWayland the portal refuses the parent window.",
"file_load.built": "Built so far: {built} of {total} features.",
"file_load.converting": "Converting solid {done} of {total}",
"file_load.inserting": "Inserting geometry",
@@ -2378,7 +2350,6 @@
"prefs.dimension_values_view": "In the view",
"prefs.dimension_values_window": "In a window",
"prefs.display": "Display",
"prefs.drawings": "Drawings",
"prefs.edges": "Edges and shading",
"prefs.extend": "Extend",
"prefs.extra_navigation": "Ctrl+left drag on empty space orbits; hold Space to pan.",
@@ -2588,6 +2559,8 @@
"prefs.section_hatch_by_part": "By part",
"prefs.section_hatch_one": "One style",
"prefs.select": "Select",
"prefs.select_hidden_edges": "Select hidden edges in Shaded with hidden edges",
"prefs.select_hidden_edges_help": "Select edges through faces when Shaded with hidden edges is active, including for Fillet. Off keeps v0.30.1 selection behavior. Hidden bodies stay unselectable.",
"prefs.select_one_item": "Select one item",
"prefs.select_other_hidden": "Select hidden geometry with Select Other",
"prefs.select_other_hidden_help": "On: include hidden bodies, sketches and occluded edges near the pointer. Off: use the original list of visible geometry under the pointer.",
@@ -2612,6 +2585,8 @@
"prefs.sketch.click_selects": "Click selects",
"prefs.sketch.conflict_colour": "Conflict color",
"prefs.sketch.drawing": "Drawing",
"prefs.sketch.ellipse_axes": "Draw ellipses with their axes",
"prefs.sketch.ellipse_axes_note": "The major and minor axes as construction lines, so the half-axes and axes can be dimensioned. Off: the ellipse alone, and D on it gives its minor radius wherever the value is pulled, drawn and labelled R as before.",
"prefs.sketch.exit_keep": "Keep",
"prefs.sketch.exit_previous": "Return to the view before",
"prefs.sketch.exit_unless_rotated": "Unless turned",
@@ -4202,6 +4177,32 @@
"sketch.dof_left": "{count} degrees of freedom left",
"sketch.dof_left_one": "{count} degree of freedom left",
"sketch.draw_curves": "Draw as {linetype}",
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
"sketch.error.nothing_to_break": "nothing to break here",
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
"sketch.hint.break": "Break: click a curve where it should split",
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
"sketch.hint.fallback": "Click in the sketch",
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
"sketch.hint.open_done": "Open done",
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
"sketch.hint.slot": "Slot: click both ends, then the width",
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
"sketch.make_selection": "Convert the selection to {linetype}",
"sketch.open_ends": "Open {count}",
"sketch.status_conflict": "constraints conflict",
@@ -5015,7 +5016,6 @@
"ui.fine": "Fine",
"ui.finest": "Finest",
"ui.finish_sketch": "Finish Sketch",
"ui.first_angle": "First angle",
"ui.fit": "Fit",
"ui.fix_unfix": "Fix/Unfix",
"ui.flat": "Flat",
@@ -5052,6 +5052,7 @@
"ui.heading_construct": "CONSTRUCT",
"ui.heading_create": "CREATE",
"ui.heading_create_sketch": "CREATE SKETCH",
"ui.heading_custom_view_navigator": "CUSTOM VIEW NAVIGATOR",
"ui.heading_cylinder": "CYLINDER",
"ui.heading_design": "DESIGN",
"ui.heading_draft": "DRAFT",
@@ -5320,7 +5321,6 @@
"ui.profiles": "Profiles",
"ui.project": "Project",
"ui.project_face_edges_sketch": "Project face edges into new sketches",
"ui.projection_of_new_drawings": "Projection of new drawings",
"ui.properties": "Properties",
"ui.properties.copy_matrix": "Copy matrix",
"ui.properties.first_moments": "First moments",
@@ -5506,7 +5506,6 @@
"ui.text": "Text",
"ui.theme": "Theme",
"ui.thickness": "Thickness",
"ui.third_angle": "Third angle",
"ui.thread": "Thread",
"ui.tie_ends": "Tie",
"ui.tie_ends_tip": "Constrain all untied ends",
@@ -5649,6 +5648,14 @@
"values must be numbers or expressions": "values must be numbers or expressions",
"view.auto_ortho_faces": "Perspective while orbiting, ortho on faces",
"view.render_inspector": "Render Inspector",
"view_gizmo.arrows": "Rotation arrows",
"view_gizmo.home": "Home button",
"view_gizmo.no_such_body": "This design has no body of that name.",
"view_gizmo.note": "Pick a body in the view. It is kept with your preferences and settings profiles, and shown in every design. Click one of its faces to look straight at it.",
"view_gizmo.texture": "Texture",
"view_gizmo.themed": "Themed",
"view_gizmo.too_detailed": "Too detailed for the view navigator (over 10,000 triangles)",
"view_gizmo.too_thin": "Too long and thin for the view navigator: its thinnest side must be at least a fifth of its longest",
"web.files.insert_drawings_hint": "CAD files, meshes, DXF or SVG into the open design",
"web.files.insert_stored_drawings_hint": "Into the open design (CAD files, meshes, DXF, SVG)",
"web.open_full": "Open in SolidCraft",
+791 -1
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+44
View File
@@ -85,6 +85,7 @@
"Bottom Right": "למטה מימין",
"Boundary": "גבול",
"Bounding box": "תיבה תוחמת",
"Bridge": "גשר",
"CHECK": "בדיקה",
"COMPONENTS": "רכיבים",
"CONFIGURATIONS": "תצורות",
@@ -1296,6 +1297,7 @@
"cmd.timeline_stop": "עצירת ציר הזמן",
"cmd.timeline_suppress": "השבתת פיצ'ר",
"cmd.ui.assistant": "הצגה או הסתרה של העוזר",
"cmd.view.navigator": "ניווט תצוגה מותאם אישית",
"cmd.view.render_browser": "דפדפן רינדור",
"cmd.view.render_dock": "הזז את עגינת הרינדור",
"cmd.view.render_focus": "תצוגת מיקוד רינדור",
@@ -1445,6 +1447,9 @@
"each rail's curves must be in one sketch": "עקומות כל מסילה חייבות להיות בסקיצה אחת",
"feature computation ended without a result": "חישוב הפיצ'ר הסתיים ללא תוצאה",
"file.open.drawing_filter": "תכנים, קובצי CAD, רשתות משולשים ושרטוטים",
"file.open.portal_fallback.no_portal_service": "בורר הקבצים שנפתח אינו של שולחן העבודה: שירות בחירת הקבצים של שולחן העבודה אינו זמין.",
"file.open.portal_fallback.no_session_bus": "בורר הקבצים שנפתח אינו של שולחן העבודה: אין אוטובוס הפעלה של D-Bus.",
"file.open.portal_fallback.xwayland": "בורר הקבצים שנפתח אינו של שולחן העבודה: תחת XWayland הפורטל דוחה את חלון האב.",
"file_load.built": "נבנו עד כה: {built} מתוך {total} פיצ'רים.",
"file_load.converting": "המרת גוף מוצק {done} מתוך {total}",
"file_load.inserting": "הוספת גאומטריה",
@@ -2554,6 +2559,8 @@
"prefs.section_hatch_by_part": "לפי חלק",
"prefs.section_hatch_one": "סגנון אחד",
"prefs.select": "בחירה",
"prefs.select_hidden_edges": "בחירת קצוות מוסתרים בתצוגה מוצללת עם קצוות מוסתרים",
"prefs.select_hidden_edges_help": "מאפשר לבחור קצוות דרך פאות בתצוגה מוצללת עם קצוות מוסתרים, גם עבור Fillet. כיבוי שומר על אופן הבחירה של v0.30.1. גופים שהוסתרו עדיין אינם ניתנים לבחירה.",
"prefs.select_one_item": "בחירת פריט אחד",
"prefs.select_other_hidden": "בחירת גאומטריה מוסתרת באמצעות בחירה אחרת",
"prefs.select_other_hidden_help": "מופעל: כולל גופים, סקיצות וקצוות מוסתרים בקרבת הסמן. כבוי: שימוש ברשימה המקורית של גאומטריה גלויה מתחת לסמן.",
@@ -2578,6 +2585,8 @@
"prefs.sketch.click_selects": "לחיצה בוחרת",
"prefs.sketch.conflict_colour": "צבע התנגשות",
"prefs.sketch.drawing": "שרטוט",
"prefs.sketch.ellipse_axes": "שרטוט אליפסות עם הצירים שלהן",
"prefs.sketch.ellipse_axes_note": "הציר הראשי והציר המשני כקווי עזר, כדי שאפשר יהיה לתת מידות לחצאי הצירים ולצירים. כבוי: האליפסה בלבד, ו-D עליה נותן את הרדיוס המשני שלה בכל מקום שאליו נגרר הערך, משורטט ומסומן R כמו קודם.",
"prefs.sketch.exit_keep": "שמירה",
"prefs.sketch.exit_previous": "חזרה לתצוגה הקודמת",
"prefs.sketch.exit_unless_rotated": "אלא אם סובבה",
@@ -4168,6 +4177,32 @@
"sketch.dof_left": "נותרו {count} דרגות חופש",
"sketch.dof_left_one": "נותרה {count} דרגת חופש",
"sketch.draw_curves": "שרטוט כ־{linetype}",
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
"sketch.error.nothing_to_break": "nothing to break here",
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
"sketch.hint.break": "Break: click a curve where it should split",
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
"sketch.hint.fallback": "Click in the sketch",
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
"sketch.hint.open_done": "Open done",
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
"sketch.hint.slot": "Slot: click both ends, then the width",
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
"sketch.make_selection": "המרת הבחירה ל־{linetype}",
"sketch.open_ends": "קצוות פתוחים: {count}",
"sketch.status_conflict": "אילוצים סותרים",
@@ -5017,6 +5052,7 @@
"ui.heading_construct": "עזר",
"ui.heading_create": "יצירה",
"ui.heading_create_sketch": "יצירת סקיצה",
"ui.heading_custom_view_navigator": "ניווט תצוגה מותאם אישית",
"ui.heading_cylinder": "גליל",
"ui.heading_design": "תכן",
"ui.heading_draft": "שיפוע יציקה",
@@ -5612,6 +5648,14 @@
"values must be numbers or expressions": "הערכים חייבים להיות מספרים או ביטויים",
"view.auto_ortho_faces": "פרספקטיבה בזמן סיבוב, אורתוגרפי על פאות",
"view.render_inspector": "מפקח רינדור",
"view_gizmo.arrows": "חיצי סיבוב",
"view_gizmo.home": "לחצן תצוגת בית",
"view_gizmo.no_such_body": "אין בעיצוב הזה גוף בשם הזה.",
"view_gizmo.note": "בחרו גוף בתצוגה. הוא נשמר עם ההעדפות ופרופילי ההגדרות שלכם ומוצג בכל עיצוב. לחצו על אחת מפאותיו כדי להביט בה ישירות.",
"view_gizmo.texture": "מרקם",
"view_gizmo.themed": "לפי ערכת הנושא",
"view_gizmo.too_detailed": "מפורט מדי לניווט התצוגה (יותר מ-10,000 משולשים)",
"view_gizmo.too_thin": "ארוך ודק מדי לניווט התצוגה: הצד הדק ביותר חייב להיות לפחות חמישית מהצד הארוך ביותר",
"web.files.insert_drawings_hint": "קובצי CAD, רשתות משולשים, DXF או SVG אל התכן הפתוח",
"web.files.insert_stored_drawings_hint": "אל התכן הפתוח (קובצי CAD, רשתות משולשים, DXF, SVG)",
"web.open_full": "פתיחה ב־SolidCraft",
+44 -37
View File
@@ -85,6 +85,7 @@
"Bottom Right": "右下",
"Boundary": "境界",
"Bounding box": "境界ボックス",
"Bridge": "ブリッジ",
"CHECK": "チェック",
"COMPONENTS": "コンポーネント",
"CONFIGURATIONS": "構成",
@@ -833,8 +834,6 @@
"cmd.appearance_pick_up": "外観を取得",
"cmd.appearance_replace": "外観を置換",
"cmd.assembly_bom": "部品表",
"cmd.assembly_bom_item_set": "項目番号の変更",
"cmd.assembly_bom_renumber": "項目番号の振り直し",
"cmd.assembly_mass_properties": "質量特性",
"cmd.body.print_face": "造形面",
"cmd.body_rename": "ボディの名前を変更",
@@ -934,36 +933,6 @@
"cmd.document_metadata_set": "デザインのプロパティを変更",
"cmd.document_revision": "デザイン リビジョン",
"cmd.document_units": "デザインの単位を変更",
"cmd.drawing_balloon_add": "バルーンを追加",
"cmd.drawing_balloon_edit": "バルーンを編集",
"cmd.drawing_balloon_inspect": "バルーンを確認",
"cmd.drawing_close": "図面を閉じる",
"cmd.drawing_create": "図面を作成",
"cmd.drawing_delete": "図面を削除",
"cmd.drawing_dimension_add": "図面寸法を追加",
"cmd.drawing_dimension_edit": "図面寸法を編集",
"cmd.drawing_dimension_inspect": "図面寸法を確認",
"cmd.drawing_export": "シートを書き出す",
"cmd.drawing_inspect": "図面を確認",
"cmd.drawing_list": "図面を一覧表示",
"cmd.drawing_open": "図面を開く",
"cmd.drawing_properties": "図面のプロパティ",
"cmd.drawing_properties_remove": "図面のプロパティを削除",
"cmd.drawing_properties_set": "図面のプロパティを変更",
"cmd.drawing_revision_add": "リビジョンを追加",
"cmd.drawing_revision_delete": "リビジョンを削除",
"cmd.drawing_revision_edit": "リビジョンを編集",
"cmd.drawing_scene": "シートを描画",
"cmd.drawing_set": "図面設定",
"cmd.drawing_sheet_add": "シートを追加",
"cmd.drawing_sheet_delete": "シートを削除",
"cmd.drawing_sheet_reorder": "シートを並べ替え",
"cmd.drawing_sheet_set": "シート設定",
"cmd.drawing_view_copy_to_sheet": "ビューをシートにコピー",
"cmd.drawing_view_edit": "図面ビューを編集",
"cmd.drawing_view_inspect": "図面ビューを確認",
"cmd.drawing_view_move_to_sheet": "ビューをシートに移動",
"cmd.drawing_view_snapshot": "図面ビューのスナップショット",
"cmd.edit_checkpoint": "チェックポイント",
"cmd.edit_repeat": "最後のコマンドを繰り返す",
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
@@ -1061,7 +1030,6 @@
"cmd.motion_trace_edit": "モーション トレースを編集",
"cmd.motion_trace_keep": "モーション トレースを保持",
"cmd.occurrence_align": "オカレンスを整列",
"cmd.occurrence_bom_type": "部品表の種類",
"cmd.occurrence_copy": "貼り付け(インスタンス)",
"cmd.occurrence_drag": "オカレンスをドラッグ",
"cmd.occurrence_ground": "空間固定",
@@ -1329,6 +1297,7 @@
"cmd.timeline_stop": "履歴を停止",
"cmd.timeline_suppress": "フィーチャーを抑制",
"cmd.ui.assistant": "アシスタントの表示切り替え",
"cmd.view.navigator": "カスタム ビューナビゲーター",
"cmd.view.render_browser": "Renderブラウザー",
"cmd.view.render_dock": "レンダードックを移動",
"cmd.view.render_focus": "Renderフォーカス表示",
@@ -1478,6 +1447,9 @@
"each rail's curves must be in one sketch": "各レールの曲線は1つのスケッチ内にある必要があります",
"feature computation ended without a result": "フィーチャーの計算が結果なしで終了しました",
"file.open.drawing_filter": "デザイン、CADファイル、メッシュ、図面",
"file.open.portal_fallback.no_portal_service": "開いたファイル選択はデスクトップのものではありません。デスクトップのファイル選択サービスが利用できません。",
"file.open.portal_fallback.no_session_bus": "開いたファイル選択はデスクトップのものではありません。D-Bus セッションがありません。",
"file.open.portal_fallback.xwayland": "開いたファイル選択はデスクトップのものではありません。XWayland ではポータルが親ウィンドウを拒否します。",
"file_load.built": "構築済み: 全{total}フィーチャー中{built}件。",
"file_load.converting": "ソリッドを変換中: {done}/{total}",
"file_load.inserting": "ジオメトリを挿入中",
@@ -2378,7 +2350,6 @@
"prefs.dimension_values_view": "ビュー内",
"prefs.dimension_values_window": "ウィンドウ",
"prefs.display": "表示",
"prefs.drawings": "図面",
"prefs.edges": "エッジと陰影",
"prefs.extend": "拡張",
"prefs.extra_navigation": "何もない場所でCtrl+左ドラッグすると回転し、Spaceを押しながらドラッグすると移動します。",
@@ -2588,6 +2559,8 @@
"prefs.section_hatch_by_part": "部品ごと",
"prefs.section_hatch_one": "単一スタイル",
"prefs.select": "選択",
"prefs.select_hidden_edges": "隠線表示で隠れた辺を選択する",
"prefs.select_hidden_edges_help": "「Shaded with hidden edges」で面の向こうの辺を選択し、Filletにも使用できます。オフではv0.30.1相当の選択動作になります。非表示のボディは選択対象になりません。",
"prefs.select_one_item": "項目を 1 つ選択",
"prefs.select_other_hidden": "「他を選択」で非表示のジオメトリを選択",
"prefs.select_other_hidden_help": "オン: ポインター付近の非表示のボディやスケッチ、遮られたエッジを含めます。オフ: ポインター直下の表示中のジオメトリだけを元のリストに表示します。",
@@ -2612,6 +2585,8 @@
"prefs.sketch.click_selects": "クリックで選択",
"prefs.sketch.conflict_colour": "競合色",
"prefs.sketch.drawing": "作図",
"prefs.sketch.ellipse_axes": "楕円を軸と一緒に描く",
"prefs.sketch.ellipse_axes_note": "長軸と短軸をコンストラクション線として描き、半軸と軸に寸法を付けられるようにします。オフ: 楕円のみを描き、D で短半径を寸法にします。値をどこへ引いても、これまでどおり R 付きで描かれます。",
"prefs.sketch.exit_keep": "維持",
"prefs.sketch.exit_previous": "開始前のビューに戻る",
"prefs.sketch.exit_unless_rotated": "回転した場合を除く",
@@ -4202,6 +4177,32 @@
"sketch.dof_left": "残り自由度 {count}",
"sketch.dof_left_one": "残り自由度 {count}",
"sketch.draw_curves": "{linetype}として描く",
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
"sketch.error.nothing_to_break": "nothing to break here",
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
"sketch.hint.break": "Break: click a curve where it should split",
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
"sketch.hint.fallback": "Click in the sketch",
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
"sketch.hint.open_done": "Open done",
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
"sketch.hint.slot": "Slot: click both ends, then the width",
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
"sketch.make_selection": "選択を{linetype}にする",
"sketch.open_ends": "開放 {count}",
"sketch.status_conflict": "拘束が競合しています",
@@ -5015,7 +5016,6 @@
"ui.fine": "細かい",
"ui.finest": "最も細かい",
"ui.finish_sketch": "スケッチを終了",
"ui.first_angle": "第一角法",
"ui.fit": "フィット",
"ui.fix_unfix": "固定/固定解除",
"ui.flat": "展開",
@@ -5052,6 +5052,7 @@
"ui.heading_construct": "構築",
"ui.heading_create": "作成",
"ui.heading_create_sketch": "スケッチを作成",
"ui.heading_custom_view_navigator": "カスタム ビューナビゲーター",
"ui.heading_cylinder": "円柱",
"ui.heading_design": "デザイン",
"ui.heading_draft": "勾配",
@@ -5320,7 +5321,6 @@
"ui.profiles": "プロファイル",
"ui.project": "投影",
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
"ui.projection_of_new_drawings": "新規図面の投影法",
"ui.properties": "プロパティ",
"ui.properties.copy_matrix": "行列をコピー",
"ui.properties.first_moments": "一次モーメント",
@@ -5506,7 +5506,6 @@
"ui.text": "テキスト",
"ui.theme": "テーマ",
"ui.thickness": "厚さ",
"ui.third_angle": "第三角法",
"ui.thread": "ねじ",
"ui.tie_ends": "拘束",
"ui.tie_ends_tip": "未拘束をすべて拘束",
@@ -5649,6 +5648,14 @@
"values must be numbers or expressions": "値は数値または式にしてください",
"view.auto_ortho_faces": "回転中は透視投影、面を正面に見ると平行投影",
"view.render_inspector": "レンダーインスペクター",
"view_gizmo.arrows": "回転矢印",
"view_gizmo.home": "ホーム ボタン",
"view_gizmo.no_such_body": "このデザインにその名前のボディはありません。",
"view_gizmo.note": "ビューでボディを選択してください。ボディは環境設定と設定プロファイルに保存され、すべてのデザインで表示されます。面をクリックすると、その面を正面から見ます。",
"view_gizmo.texture": "テクスチャ",
"view_gizmo.themed": "テーマ",
"view_gizmo.too_detailed": "ビューナビゲーターには細かすぎます (10,000 三角形を超えています)",
"view_gizmo.too_thin": "ビューナビゲーターには細長すぎます: 最も薄い辺が最も長い辺の 5 分の 1 以上である必要があります",
"web.files.insert_drawings_hint": "CADファイル、メッシュ、DXF、SVGを開いているデザインに挿入",
"web.files.insert_stored_drawings_hint": "開いているデザインに挿入(CADファイル、メッシュ、DXF、SVG)",
"web.open_full": "SolidCraft で開く",
+17 -7
View File
@@ -724,8 +724,6 @@
"cmd.appearance_pick_up": "모양 가져오기",
"cmd.appearance_replace": "모양 바꾸기",
"cmd.assembly_bom": "자재 명세서",
"cmd.assembly_bom_item_set": "항목 번호 변경",
"cmd.assembly_bom_renumber": "항목 번호 다시 매기기",
"cmd.assembly_mass_properties": "질량 특성",
"cmd.body.print_face": "출력 바닥면",
"cmd.body_rename": "바디 이름 변경",
@@ -896,7 +894,6 @@
"cmd.motion_study": "운동 연구",
"cmd.motion_trace": "모션 궤적",
"cmd.motion_trace_keep": "모션 궤적 유지",
"cmd.occurrence_bom_type": "자재 명세서 유형",
"cmd.occurrence_copy": "붙여넣기(인스턴스)",
"cmd.occurrence_ground": "고정",
"cmd.occurrence_move": "인스턴스 이동",
@@ -1136,6 +1133,7 @@
"cmd.timeline_stop": "기록 재생 중지",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.ui.assistant": "도우미 표시 전환",
"cmd.view.navigator": "사용자 지정 뷰 탐색기",
"cmd.view.render_browser": "렌더 브라우저",
"cmd.view.render_dock": "렌더 도크 이동",
"cmd.view.render_focus": "렌더 집중 보기",
@@ -1243,6 +1241,9 @@
"dxf.tolerance_help": "표시된 바디와 스케치만 내보내며 곡선은 직선 구간으로 근사합니다. 허용 오차가 작을수록 곡선이 매끄러워지지만 처리 시간과 메모리 사용량이 늘어납니다.",
"each rail's curves must be in one sketch": "각 레일의 곡선은 하나의 스케치 안에 있어야 합니다",
"feature computation ended without a result": "특성 계산이 결과 없이 끝났습니다",
"file.open.portal_fallback.no_portal_service": "열린 파일 선택은 데스크톱의 것이 아닙니다: 데스크톱의 파일 선택 서비스를 사용할 수 없습니다.",
"file.open.portal_fallback.no_session_bus": "열린 파일 선택은 데스크톱의 것이 아닙니다: D-Bus 세션 버스가 없습니다.",
"file.open.portal_fallback.xwayland": "열린 파일 선택은 데스크톱의 것이 아닙니다: XWayland에서는 포털이 부모 창을 거부합니다.",
"file_load.built": "지금까지 빌드됨: 피처 {total}개 중 {built}개.",
"file_load.converting": "솔리드 변환 중 {done}/{total}",
"file_load.inserting": "기하 삽입 중",
@@ -1735,7 +1736,6 @@
"prefs.dimension_values_view": "뷰에서",
"prefs.dimension_values_window": "창에서",
"prefs.display": "표시",
"prefs.drawings": "도면",
"prefs.edges": "모서리 및 음영",
"prefs.extend": "확장",
"prefs.extra_navigation": "Ctrl+왼쪽 드래그로 빈 공간에서 회전하고, Space를 누른 채 이동합니다.",
@@ -1893,6 +1893,8 @@
"prefs.search": "설정 검색",
"prefs.search_empty": "일치하는 설정이 없습니다",
"prefs.select": "선택",
"prefs.select_hidden_edges": "숨은 모서리가 표시된 음영 뷰에서 숨은 모서리 선택",
"prefs.select_hidden_edges_help": "숨은 모서리가 표시된 음영 뷰에서 면 뒤의 모서리를 선택하여 필렛에 사용할 수 있습니다. 끄면 v0.30.1의 선택 동작을 유지합니다. 숨긴 바디는 선택되지 않습니다.",
"prefs.select_one_item": "항목 하나 선택",
"prefs.select_other_hidden": "다른 항목 선택으로 숨겨진 형상 선택",
"prefs.select_other_hidden_help": "켜짐: 포인터 근처의 숨겨진 바디, 스케치 및 가려진 모서리를 포함합니다. 꺼짐: 포인터 바로 아래에 보이는 형상만 포함하는 기존 목록을 사용합니다.",
@@ -1917,6 +1919,8 @@
"prefs.sketch.click_selects": "클릭으로 선택",
"prefs.sketch.conflict_colour": "충돌 색상",
"prefs.sketch.drawing": "그리기",
"prefs.sketch.ellipse_axes": "타원을 축과 함께 그리기",
"prefs.sketch.ellipse_axes_note": "장축과 단축을 구성선으로 그려 반축과 축에 치수를 지정할 수 있습니다. 끄면: 타원만 그리며, D는 값을 어디로 끌든 단반경을 주며, 이전처럼 R로 표시되어 그려집니다.",
"prefs.sketch.exit_keep": "유지",
"prefs.sketch.exit_previous": "이전 뷰로 돌아가기",
"prefs.sketch.exit_unless_rotated": "회전하지 않았다면",
@@ -3754,7 +3758,6 @@
"ui.fine": "세밀",
"ui.finest": "가장 세밀",
"ui.finish_sketch": "스케치 종료",
"ui.first_angle": "제1각법",
"ui.fit": "맞춤",
"ui.fix_unfix": "고정/고정 해제",
"ui.flat": "플랫",
@@ -3789,6 +3792,7 @@
"ui.heading_construct": "구성",
"ui.heading_create": "만들기",
"ui.heading_create_sketch": "스케치 만들기",
"ui.heading_custom_view_navigator": "사용자 지정 뷰 탐색기",
"ui.heading_cylinder": "원통",
"ui.heading_design": "디자인",
"ui.heading_draft": "구배",
@@ -4028,7 +4032,6 @@
"ui.profiles": "프로파일",
"ui.project": "투영",
"ui.project_face_edges_sketch": "면 모서리를 새 스케치로 투영",
"ui.projection_of_new_drawings": "새 도면의 투상법",
"ui.properties": "속성",
"ui.properties.copy_matrix": "행렬 복사",
"ui.properties.first_moments": "1차 모멘트",
@@ -4188,7 +4191,6 @@
"ui.text": "텍스트",
"ui.theme": "테마",
"ui.thickness": "두께",
"ui.third_angle": "제3각법",
"ui.thread": "나사산",
"ui.tie_ends": "연결",
"ui.tie_ends_tip": "연결되지 않은 모든 끝점 구속",
@@ -4318,5 +4320,13 @@
"values must be finite numbers": "값은 유한한 숫자여야 합니다",
"values must be numbers or expressions": "값은 숫자 또는 식이어야 합니다",
"view.render_inspector": "렌더 검사기",
"view_gizmo.arrows": "회전 화살표",
"view_gizmo.home": "홈 버튼",
"view_gizmo.no_such_body": "이 디자인에 그런 이름의 바디가 없습니다.",
"view_gizmo.note": "뷰에서 바디를 선택하십시오. 바디는 환경설정과 설정 프로필에 저장되어 모든 디자인에 표시됩니다. 면을 클릭하면 그 면을 정면으로 봅니다.",
"view_gizmo.texture": "텍스처",
"view_gizmo.themed": "테마",
"view_gizmo.too_detailed": "뷰 탐색기에 너무 세밀합니다 (삼각형 10,000개 초과)",
"view_gizmo.too_thin": "뷰 탐색기에 너무 가늘고 깁니다: 가장 얇은 변이 가장 긴 변의 5분의 1 이상이어야 합니다",
"↑ ↓ choose · Enter / Tab complete · Esc dismiss": "↑ ↓ 선택 · Enter / Tab 완성 · Esc 닫기"
}
@@ -1,34 +0,0 @@
{
"en": {
"export.paper.blueprint": "Blueprint Blue",
"export.paper.prussian_blue": "Prussian Blue (Historical)"
},
"ar": {
"export.paper.blueprint": "أزرق المخطط",
"export.paper.prussian_blue": "أزرق بروسي (تاريخي)"
},
"de": {
"export.paper.blueprint": "Blaupausenblau",
"export.paper.prussian_blue": "Preußischblau (historisch)"
},
"he": {
"export.paper.blueprint": "כחול שרטוט",
"export.paper.prussian_blue": "כחול פרוסי (היסטורי)"
},
"ja": {
"export.paper.blueprint": "ブループリントブルー",
"export.paper.prussian_blue": "プルシアンブルー(歴史的)"
},
"ko": {
"export.paper.blueprint": "블루프린트 블루",
"export.paper.prussian_blue": "프러시안 블루(역사적)"
},
"vi": {
"export.paper.blueprint": "Xanh bản vẽ",
"export.paper.prussian_blue": "Xanh Phổ (lịch sử)"
},
"zh": {
"export.paper.blueprint": "蓝图蓝",
"export.paper.prussian_blue": "普鲁士蓝(历史)"
}
}
-26
View File
@@ -1,26 +0,0 @@
{
"en": {
"cmd.drawing_image_add": "Add Image"
},
"ar": {
"cmd.drawing_image_add": "إضافة صورة"
},
"de": {
"cmd.drawing_image_add": "Bild hinzufügen"
},
"he": {
"cmd.drawing_image_add": "הוספת תמונה"
},
"ja": {
"cmd.drawing_image_add": "画像を追加"
},
"ko": {
"cmd.drawing_image_add": "이미지 추가"
},
"vi": {
"cmd.drawing_image_add": "Thêm hình ảnh"
},
"zh": {
"cmd.drawing_image_add": "添加图像"
}
}
-42
View File
@@ -1,42 +0,0 @@
{
"en": {
"cmd.drawing_note_add": "Add Note",
"cmd.drawing_note_edit": "Edit Note",
"cmd.drawing_note_inspect": "Inspect Note"
},
"ar": {
"cmd.drawing_note_add": "إضافة ملاحظة",
"cmd.drawing_note_edit": "تحرير الملاحظة",
"cmd.drawing_note_inspect": "فحص الملاحظة"
},
"de": {
"cmd.drawing_note_add": "Hinweis hinzufügen",
"cmd.drawing_note_edit": "Hinweis bearbeiten",
"cmd.drawing_note_inspect": "Hinweis prüfen"
},
"he": {
"cmd.drawing_note_add": "הוספת הערה",
"cmd.drawing_note_edit": "עריכת הערה",
"cmd.drawing_note_inspect": "בדיקת הערה"
},
"ja": {
"cmd.drawing_note_add": "注記を追加",
"cmd.drawing_note_edit": "注記を編集",
"cmd.drawing_note_inspect": "注記を確認"
},
"ko": {
"cmd.drawing_note_add": "주석 추가",
"cmd.drawing_note_edit": "주석 편집",
"cmd.drawing_note_inspect": "주석 확인"
},
"vi": {
"cmd.drawing_note_add": "Thêm ghi chú",
"cmd.drawing_note_edit": "Sửa ghi chú",
"cmd.drawing_note_inspect": "Kiểm tra ghi chú"
},
"zh": {
"cmd.drawing_note_add": "添加注释",
"cmd.drawing_note_edit": "编辑注释",
"cmd.drawing_note_inspect": "检查注释"
}
}
@@ -1,34 +0,0 @@
{
"en": {
"cmd.drawing_balloon_repair": "Repair Balloon",
"cmd.drawing_dimension_repair": "Repair Drawing Dimension"
},
"ar": {
"cmd.drawing_balloon_repair": "إصلاح البالون",
"cmd.drawing_dimension_repair": "إصلاح بُعد اللوحة"
},
"de": {
"cmd.drawing_balloon_repair": "Positionsnummer reparieren",
"cmd.drawing_dimension_repair": "Zeichnungsbemaßung reparieren"
},
"he": {
"cmd.drawing_balloon_repair": "תיקון בלון",
"cmd.drawing_dimension_repair": "תיקון מידת שרטוט"
},
"ja": {
"cmd.drawing_balloon_repair": "バルーンを修復",
"cmd.drawing_dimension_repair": "図面寸法を修復"
},
"ko": {
"cmd.drawing_balloon_repair": "벌룬 복구",
"cmd.drawing_dimension_repair": "도면 치수 복구"
},
"vi": {
"cmd.drawing_balloon_repair": "Sửa chữa bóng chú thích",
"cmd.drawing_dimension_repair": "Sửa chữa kích thước bản vẽ"
},
"zh": {
"cmd.drawing_balloon_repair": "修复零件序号",
"cmd.drawing_dimension_repair": "修复绘图尺寸"
}
}
@@ -1,26 +0,0 @@
{
"en": {
"cmd.drawing_revise": "Issue Revision"
},
"ar": {
"cmd.drawing_revise": "إصدار مراجعة"
},
"de": {
"cmd.drawing_revise": "Revision herausgeben"
},
"he": {
"cmd.drawing_revise": "הוצאת גרסה"
},
"ja": {
"cmd.drawing_revise": "リビジョンを発行"
},
"ko": {
"cmd.drawing_revise": "리비전 발행"
},
"vi": {
"cmd.drawing_revise": "Phát hành bản sửa đổi"
},
"zh": {
"cmd.drawing_revise": "发布版本"
}
}
@@ -1,42 +0,0 @@
{
"en": {
"cmd.drawing_revision_tag_add": "Add Revision Tag",
"cmd.drawing_revision_tag_edit": "Edit Revision Tag",
"cmd.drawing_revision_tag_delete": "Delete Revision Tag"
},
"ar": {
"cmd.drawing_revision_tag_add": "إضافة علامة مراجعة",
"cmd.drawing_revision_tag_edit": "تحرير علامة المراجعة",
"cmd.drawing_revision_tag_delete": "حذف علامة المراجعة"
},
"de": {
"cmd.drawing_revision_tag_add": "Revisionskennzeichen hinzufügen",
"cmd.drawing_revision_tag_edit": "Revisionskennzeichen bearbeiten",
"cmd.drawing_revision_tag_delete": "Revisionskennzeichen löschen"
},
"he": {
"cmd.drawing_revision_tag_add": "הוספת תג גרסה",
"cmd.drawing_revision_tag_edit": "עריכת תג גרסה",
"cmd.drawing_revision_tag_delete": "מחיקת תג גרסה"
},
"ja": {
"cmd.drawing_revision_tag_add": "リビジョンタグを追加",
"cmd.drawing_revision_tag_edit": "リビジョンタグを編集",
"cmd.drawing_revision_tag_delete": "リビジョンタグを削除"
},
"ko": {
"cmd.drawing_revision_tag_add": "리비전 태그 추가",
"cmd.drawing_revision_tag_edit": "리비전 태그 편집",
"cmd.drawing_revision_tag_delete": "리비전 태그 삭제"
},
"vi": {
"cmd.drawing_revision_tag_add": "Thêm thẻ sửa đổi",
"cmd.drawing_revision_tag_edit": "Sửa thẻ sửa đổi",
"cmd.drawing_revision_tag_delete": "Xóa thẻ sửa đổi"
},
"zh": {
"cmd.drawing_revision_tag_add": "添加版本标记",
"cmd.drawing_revision_tag_edit": "编辑版本标记",
"cmd.drawing_revision_tag_delete": "删除版本标记"
}
}
@@ -1,26 +0,0 @@
{
"en": {
"cmd.drawing_status": "Drawing Status"
},
"ar": {
"cmd.drawing_status": "حالة الرسم"
},
"de": {
"cmd.drawing_status": "Zeichnungsstatus"
},
"he": {
"cmd.drawing_status": "מצב השרטוט"
},
"ja": {
"cmd.drawing_status": "図面の状態"
},
"ko": {
"cmd.drawing_status": "도면 상태"
},
"vi": {
"cmd.drawing_status": "Trạng thái bản vẽ"
},
"zh": {
"cmd.drawing_status": "绘图状态"
}
}
@@ -1,58 +0,0 @@
{
"en": {
"cmd.drawing_table_add": "Add Table",
"cmd.drawing_table_delete": "Delete Table",
"cmd.drawing_table_edit": "Edit Table",
"cmd.drawing_table_inspect": "Inspect Table",
"help.drawing.table.edit": "Changes a saved table on a sheet: what it lists, where it sits, its columns and its words. Use it to adjust a parts list or revision table after adding it."
},
"ar": {
"cmd.drawing_table_add": "إضافة جدول",
"cmd.drawing_table_delete": "حذف الجدول",
"cmd.drawing_table_edit": "تحرير الجدول",
"cmd.drawing_table_inspect": "فحص الجدول",
"help.drawing.table.edit": "يغيّر جدولاً محفوظاً على ورقة: ما يعرضه ومكانه وأعمدته ونصوصه. استخدمه لضبط قائمة أجزاء أو جدول مراجعات بعد إضافته."
},
"de": {
"cmd.drawing_table_add": "Tabelle hinzufügen",
"cmd.drawing_table_delete": "Tabelle löschen",
"cmd.drawing_table_edit": "Tabelle bearbeiten",
"cmd.drawing_table_inspect": "Tabelle prüfen",
"help.drawing.table.edit": "Ändert eine gespeicherte Tabelle auf einem Blatt: was sie auflistet, wo sie steht, ihre Spalten und Texte. Damit passen Sie eine Stückliste oder Revisionstabelle nach dem Hinzufügen an."
},
"he": {
"cmd.drawing_table_add": "הוספת טבלה",
"cmd.drawing_table_delete": "מחיקת טבלה",
"cmd.drawing_table_edit": "עריכת טבלה",
"cmd.drawing_table_inspect": "בדיקת טבלה",
"help.drawing.table.edit": "משנה טבלה שמורה בגיליון: מה היא מציגה, היכן היא ממוקמת, העמודות והטקסטים שלה. השתמשו בכך כדי להתאים רשימת חלקים או טבלת גרסאות לאחר הוספתה."
},
"ja": {
"cmd.drawing_table_add": "表を追加",
"cmd.drawing_table_delete": "表を削除",
"cmd.drawing_table_edit": "表を編集",
"cmd.drawing_table_inspect": "表を確認",
"help.drawing.table.edit": "シート上の保存済みの表の内容、位置、列、文字を変更します。部品表や改訂表を追加した後の調整に使います。"
},
"ko": {
"cmd.drawing_table_add": "표 추가",
"cmd.drawing_table_delete": "표 삭제",
"cmd.drawing_table_edit": "표 편집",
"cmd.drawing_table_inspect": "표 확인",
"help.drawing.table.edit": "시트에 저장된 표의 내용, 위치, 열, 글자를 변경합니다. 부품표나 개정표를 추가한 뒤 조정할 때 사용합니다."
},
"vi": {
"cmd.drawing_table_add": "Thêm bảng",
"cmd.drawing_table_delete": "Xóa bảng",
"cmd.drawing_table_edit": "Sửa bảng",
"cmd.drawing_table_inspect": "Kiểm tra bảng",
"help.drawing.table.edit": "Thay đổi một bảng đã lưu trên trang: nội dung, vị trí, các cột và chữ của bảng. Dùng để chỉnh danh sách chi tiết hoặc bảng sửa đổi sau khi thêm."
},
"zh": {
"cmd.drawing_table_add": "添加表格",
"cmd.drawing_table_delete": "删除表格",
"cmd.drawing_table_edit": "编辑表格",
"cmd.drawing_table_inspect": "检查表格",
"help.drawing.table.edit": "更改图纸页上已保存的表格的内容、位置、列和文字。添加零件表或修订表后,可用它进行调整。"
}
}
@@ -1,26 +0,0 @@
{
"en": {
"cmd.drawing_template_apply": "Apply Drawing Template"
},
"ar": {
"cmd.drawing_template_apply": "تطبيق قالب لوحة"
},
"de": {
"cmd.drawing_template_apply": "Zeichnungsvorlage anwenden"
},
"he": {
"cmd.drawing_template_apply": "החלת תבנית שרטוט"
},
"ja": {
"cmd.drawing_template_apply": "図面テンプレートを適用"
},
"ko": {
"cmd.drawing_template_apply": "도면 템플릿 적용"
},
"vi": {
"cmd.drawing_template_apply": "Áp dụng mẫu bản vẽ"
},
"zh": {
"cmd.drawing_template_apply": "应用绘图模板"
}
}
@@ -1,26 +0,0 @@
{
"en": {
"cmd.drawing_template_save": "Save as Drawing Template"
},
"ar": {
"cmd.drawing_template_save": "حفظ كقالب لوحة"
},
"de": {
"cmd.drawing_template_save": "Als Zeichnungsvorlage speichern"
},
"he": {
"cmd.drawing_template_save": "שמירה כתבנית שרטוט"
},
"ja": {
"cmd.drawing_template_save": "図面テンプレートとして保存"
},
"ko": {
"cmd.drawing_template_save": "도면 템플릿으로 저장"
},
"vi": {
"cmd.drawing_template_save": "Lưu thành mẫu bản vẽ"
},
"zh": {
"cmd.drawing_template_save": "另存为绘图模板"
}
}
@@ -1,34 +0,0 @@
{
"en": {
"ui.template_for_new_part_drawings": "Template for new part drawings",
"ui.template_for_new_assembly_drawings": "Template for new assembly drawings"
},
"ar": {
"ui.template_for_new_part_drawings": "قالب لوحات القطع الجديدة",
"ui.template_for_new_assembly_drawings": "قالب لوحات التجميعات الجديدة"
},
"de": {
"ui.template_for_new_part_drawings": "Vorlage für neue Teilezeichnungen",
"ui.template_for_new_assembly_drawings": "Vorlage für neue Baugruppenzeichnungen"
},
"he": {
"ui.template_for_new_part_drawings": "תבנית לשרטוטי חלקים חדשים",
"ui.template_for_new_assembly_drawings": "תבנית לשרטוטי מכלולים חדשים"
},
"ja": {
"ui.template_for_new_part_drawings": "新しい部品図面のテンプレート",
"ui.template_for_new_assembly_drawings": "新しいアセンブリ図面のテンプレート"
},
"ko": {
"ui.template_for_new_part_drawings": "새 부품 도면의 템플릿",
"ui.template_for_new_assembly_drawings": "새 어셈블리 도면의 템플릿"
},
"vi": {
"ui.template_for_new_part_drawings": "Mẫu cho bản vẽ chi tiết mới",
"ui.template_for_new_assembly_drawings": "Mẫu cho bản vẽ cụm lắp ráp mới"
},
"zh": {
"ui.template_for_new_part_drawings": "新零件绘图的模板",
"ui.template_for_new_assembly_drawings": "新装配体绘图的模板"
}
}
@@ -1,26 +0,0 @@
{
"en": {
"cmd.drawing_template_list": "List Drawing Templates"
},
"ar": {
"cmd.drawing_template_list": "سرد قوالب اللوحات"
},
"de": {
"cmd.drawing_template_list": "Zeichnungsvorlagen auflisten"
},
"he": {
"cmd.drawing_template_list": "רשימת תבניות שרטוט"
},
"ja": {
"cmd.drawing_template_list": "図面テンプレートを一覧表示"
},
"ko": {
"cmd.drawing_template_list": "도면 템플릿 목록"
},
"vi": {
"cmd.drawing_template_list": "Liệt kê mẫu bản vẽ"
},
"zh": {
"cmd.drawing_template_list": "列出绘图模板"
}
}
@@ -1,26 +0,0 @@
{
"en": {
"cmd.drawing_view_base": "Base View",
"cmd.drawing_view_projected": "Projected View"
},
"ar": {
"cmd.drawing_view_base": "العرض الأساسي",
"cmd.drawing_view_projected": "العرض المُسقط"
},
"he": {
"cmd.drawing_view_base": "תצוגת בסיס",
"cmd.drawing_view_projected": "תצוגה מוטלת"
},
"ja": {
"cmd.drawing_view_base": "ベースビュー",
"cmd.drawing_view_projected": "投影ビュー"
},
"vi": {
"cmd.drawing_view_base": "Chế độ xem cơ sở",
"cmd.drawing_view_projected": "Chế độ xem chiếu"
},
"zh": {
"cmd.drawing_view_base": "基础视图",
"cmd.drawing_view_projected": "投影视图"
}
}
@@ -1,834 +0,0 @@
{
"ar": {
"0 objects": "0 عناصر",
"ASSEMBLY": "التجميع",
"Add Drawing Revision": "إضافة مراجعة للوحة",
"Add Revision": "إضافة مراجعة",
"Add sheet (not implemented)": "إضافة ورقة (غير منفّذ)",
"Align Annotations": "محاذاة التعليقات التوضيحية",
"Align Notes": "محاذاة الملاحظات",
"Align Views": "محاذاة المساقط",
"Annotate": "إضافة تعليقات توضيحية",
"Apply (not implemented)": "تطبيق (غير منفّذ)",
"Auto (Theme)": "تلقائي (السمة)",
"Balloon": "بالون",
"Base View": "المسقط الأساسي",
"Border & template": "الإطار والقالب",
"Browse Drawing Resources": "تصفح موارد اللوحات",
"CHANGES": "التغييرات",
"Centre Mark": "علامة المركز",
"Check Drawing": "فحص اللوحة",
"Close drawing panel": "إغلاق لوحة إعدادات الرسم",
"DRAWING ANNOTATE": "تعليقات اللوحة",
"DRAWING LAYOUT": "تخطيط اللوحة",
"DRAWING PARTS": "أجزاء اللوحة",
"DRAWING REVIEW": "مراجعة اللوحة",
"Datum": "مرجع",
"Detail View": "مسقط تفصيلي",
"Dock at bottom": "إرساء في الأسفل",
"Dock at top": "إرساء في الأعلى",
"Dock beneath Browser": "إرساء أسفل المتصفح",
"Dock left": "إرساء يسارًا",
"Dock right": "إرساء يمينًا",
"Dock sheet tabs": "إرساء علامات تبويب الأوراق",
"Drafting defaults": "إعدادات الرسم الافتراضية",
"Drawing & title block": "اللوحة وخانة العنوان",
"Drawing Dimension": "بُعد اللوحة",
"Drawing Line": "خط اللوحة",
"Drawing Note": "ملاحظة اللوحة",
"Drawing defaults (not available)": "إعدادات اللوحة الافتراضية (غير متاحة)",
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "لم تُنفّذ ملفات اللوحات ولا عارض الورق بعد. تعرض الأدوات المخطط لها خياراتها حيثما حُدّدت.",
"Drawing workspace": "مساحة عمل اللوحات",
"Edit Drawing Table": "تعديل جدول اللوحة",
"Edit Table": "تعديل الجدول",
"Export Drawing": "تصدير اللوحة",
"Finish the current operation before entering Drawing": "أكمل العملية الحالية قبل الدخول إلى مساحة اللوحات",
"Hole Callout": "بيان الثقب",
"Image": "صورة",
"Inherited from parent view": "موروث من المسقط الأصل",
"Insert Image": "إدراج صورة",
"Leader": "خط إشارة",
"Leader Note": "ملاحظة بخط إشارة",
"Manage sheets (not implemented)": "إدارة الأوراق (غير منفّذ)",
"Manage sheets and docking": "إدارة الأوراق والإرساء",
"NOTES": "الملاحظات",
"No drawing document": "لا يوجد مستند لوحة",
"No drawing geometry available": "لا تتوفر هندسة للوحة",
"No drawing objects": "لا توجد عناصر في اللوحة",
"No drawing sheet is available yet.": "لا تتوفر ورقة لوحة بعد.",
"No drawing view available": "لا يتوفر مسقط للوحة",
"No sheet available": "لا تتوفر ورقة",
"Not available": "غير متاح",
"Not configured": "غير مُعدّ",
"Not implemented. These options describe the planned interface; no drawing data is changed.": "غير منفّذ. تصف هذه الخيارات الواجهة المخطط لها؛ لا تتغير أي بيانات للوحة.",
"Note": "ملاحظة",
"Paper & layout": "الورق والتخطيط",
"Paper colour": "لون الورق",
"Paper colour will be available when the sheet renderer is implemented.": "سيتوفر لون الورق عند تنفيذ عارض الورقة.",
"Parent view": "المسقط الأصل",
"Parts & Tables": "الأجزاء والجداول",
"Parts List": "قائمة الأجزاء",
"Print": "طباعة",
"Print Drawing": "طباعة اللوحة",
"Profile & units": "ملف التعريف والوحدات",
"Projected View": "مسقط مشتق بالإسقاط",
"RESOURCES": "الموارد",
"REVISION": "المراجعة",
"References": "المراجع",
"Resources": "الموارد",
"Return to Design to use this command.": "عُد إلى مساحة التصميم لاستخدام هذا الأمر.",
"Review": "مراجعة",
"Revision Table": "جدول المراجعات",
"Revision Tbl.": "جدول المراجعات",
"SOURCE": "المصدر",
"SYMBOLS": "الرموز",
"Screen appearance": "مظهر الشاشة",
"Section View": "مسقط مقطعي",
"Sheet Settings": "إعدادات الورقة",
"Sheet unavailable": "الورقة غير متاحة",
"Size & orientation": "الحجم والاتجاه",
"Source BOM": "قائمة مواد المصدر",
"Source Flat": "النمط المسطح للمصدر",
"Source design": "التصميم المصدر",
"Source properties": "خصائص المصدر",
"Space": "توزيع المسافات",
"Space Annotations": "توزيع المسافات بين التعليقات التوضيحية",
"Space Notes": "توزيع المسافات بين الملاحظات",
"TABLES": "الجداول",
"Text style": "نمط النص",
"Title": "العنوان",
"Title block": "خانة العنوان",
"UI preview": "معاينة الواجهة",
"Units & precision": "الوحدات والدقة",
"Update Drawing": "تحديث اللوحة",
"View": "مسقط"
},
"he": {
"0 objects": "0 עצמים",
"ASSEMBLY": "מכלול",
"Add Drawing Revision": "הוספת גרסה לשרטוט",
"Add Revision": "הוספת גרסה",
"Add sheet (not implemented)": "הוספת גיליון (טרם מומש)",
"Align Annotations": "יישור הערות הסבר",
"Align Notes": "יישור הערות",
"Align Views": "יישור מבטים",
"Annotate": "הוספת הערות הסבר",
"Apply (not implemented)": "החלה (טרם מומש)",
"Auto (Theme)": "אוטומטי (ערכת נושא)",
"Balloon": "בלון",
"Base View": "מבט בסיס",
"Border & template": "מסגרת ותבנית",
"Browse Drawing Resources": "עיון במשאבי שרטוט",
"CHANGES": "שינויים",
"Centre Mark": "סימון מרכז",
"Check Drawing": "בדיקת שרטוט",
"Close drawing panel": "סגירת לוח השרטוט",
"DRAWING ANNOTATE": "הערות הסבר בשרטוט",
"DRAWING LAYOUT": "פריסת שרטוט",
"DRAWING PARTS": "חלקים בשרטוט",
"DRAWING REVIEW": "סקירת שרטוט",
"Datum": "ייחוס",
"Detail View": "מבט מפורט",
"Dock at bottom": "עיגון בתחתית",
"Dock at top": "עיגון בראש החלון",
"Dock beneath Browser": "עיגון מתחת לדפדפן",
"Dock left": "עיגון משמאל",
"Dock right": "עיגון מימין",
"Dock sheet tabs": "עיגון לשוניות הגיליונות",
"Drafting defaults": "ברירות מחדל לשרטוט",
"Drawing & title block": "שרטוט וטבלת כותרת",
"Drawing Dimension": "מידת שרטוט",
"Drawing Line": "קו שרטוט",
"Drawing Note": "הערת שרטוט",
"Drawing defaults (not available)": "ברירות מחדל לשרטוט (לא זמינות)",
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "קובצי שרטוט ומנגנון רינדור הנייר טרם מומשו. כלים מתוכננים מציגים את האפשרויות שלהם במקומות שבהם הוגדרו.",
"Drawing workspace": "סביבת עבודה לשרטוט",
"Edit Drawing Table": "עריכת טבלת שרטוט",
"Edit Table": "עריכת טבלה",
"Export Drawing": "ייצוא שרטוט",
"Finish the current operation before entering Drawing": "יש לסיים את הפעולה הנוכחית לפני המעבר לסביבת השרטוט",
"Hole Callout": "ציון פרטי קדח",
"Image": "תמונה",
"Inherited from parent view": "בירושה ממבט האב",
"Insert Image": "הוספת תמונה",
"Leader": "קו הפניה",
"Leader Note": "הערה עם קו הפניה",
"Manage sheets (not implemented)": "ניהול גיליונות (טרם מומש)",
"Manage sheets and docking": "ניהול גיליונות ועיגון",
"NOTES": "הערות",
"No drawing document": "אין מסמך שרטוט",
"No drawing geometry available": "אין גאומטריית שרטוט זמינה",
"No drawing objects": "אין עצמים בשרטוט",
"No drawing sheet is available yet.": "אין עדיין גיליון שרטוט זמין.",
"No drawing view available": "אין מבט שרטוט זמין",
"No sheet available": "אין גיליון זמין",
"Not available": "לא זמין",
"Not configured": "לא מוגדר",
"Not implemented. These options describe the planned interface; no drawing data is changed.": "טרם מומש. האפשרויות האלה מתארות את הממשק המתוכנן; נתוני השרטוט אינם משתנים.",
"Note": "הערה",
"Paper & layout": "נייר ופריסה",
"Paper colour": "צבע הנייר",
"Paper colour will be available when the sheet renderer is implemented.": "צבע הנייר יהיה זמין כאשר ימומש מנגנון רינדור הגיליון.",
"Parent view": "מבט אב",
"Parts & Tables": "חלקים וטבלאות",
"Parts List": "רשימת חלקים",
"Print": "הדפסה",
"Print Drawing": "הדפסת שרטוט",
"Profile & units": "פרופיל ויחידות",
"Projected View": "מבט מוטל",
"RESOURCES": "משאבים",
"REVISION": "גרסה",
"References": "הפניות",
"Resources": "משאבים",
"Return to Design to use this command.": "יש לחזור לסביבת התכן כדי להשתמש בפקודה זו.",
"Review": "סקירה",
"Revision Table": "טבלת גרסאות",
"Revision Tbl.": "טבלת גרסאות",
"SOURCE": "מקור",
"SYMBOLS": "סמלים",
"Screen appearance": "מראה המסך",
"Section View": "מבט חתך",
"Sheet Settings": "הגדרות גיליון",
"Sheet unavailable": "הגיליון אינו זמין",
"Size & orientation": "גודל וכיוון",
"Source BOM": "רשימת חלקים של המקור",
"Source Flat": "פריסת המקור",
"Source design": "תכן המקור",
"Source properties": "מאפייני המקור",
"Space": "ריווח",
"Space Annotations": "ריווח הערות הסבר",
"Space Notes": "ריווח הערות",
"TABLES": "טבלאות",
"Text style": "סגנון טקסט",
"Title": "כותרת",
"Title block": "טבלת כותרת",
"UI preview": "תצוגה מקדימה של הממשק",
"Units & precision": "יחידות ודיוק",
"Update Drawing": "עדכון שרטוט",
"View": "מבט"
},
"vi": {
"0 objects": "0 đối tượng",
"ASSEMBLY": "CỤM LẮP RÁP",
"Add Drawing Revision": "Thêm lần sửa đổi bản vẽ",
"Add Revision": "Thêm lần sửa đổi",
"Add sheet (not implemented)": "Thêm tờ (chưa triển khai)",
"Align Annotations": "Căn chỉnh chú thích",
"Align Notes": "Căn chỉnh ghi chú",
"Align Views": "Căn chỉnh hình chiếu",
"Annotate": "Thêm chú thích",
"Apply (not implemented)": "Áp dụng (chưa triển khai)",
"Auto (Theme)": "Tự động (theo giao diện)",
"Balloon": "Bóng chú thích",
"Base View": "Hình chiếu cơ sở",
"Border & template": "Khung viền và mẫu",
"Browse Drawing Resources": "Duyệt tài nguyên bản vẽ",
"CHANGES": "THAY ĐỔI",
"Centre Mark": "Dấu tâm",
"Check Drawing": "Kiểm tra bản vẽ",
"Close drawing panel": "Đóng bảng bản vẽ",
"DRAWING ANNOTATE": "CHÚ THÍCH BẢN VẼ",
"DRAWING LAYOUT": "BỐ CỤC BẢN VẼ",
"DRAWING PARTS": "CHI TIẾT BẢN VẼ",
"DRAWING REVIEW": "RÀ SOÁT BẢN VẼ",
"Datum": "Chuẩn",
"Detail View": "Hình chiếu chi tiết",
"Dock at bottom": "Gắn ở cạnh dưới",
"Dock at top": "Gắn ở cạnh trên",
"Dock beneath Browser": "Gắn bên dưới cây thư mục",
"Dock left": "Gắn bên trái",
"Dock right": "Gắn bên phải",
"Dock sheet tabs": "Gắn các thẻ tờ bản vẽ",
"Drafting defaults": "Thiết lập mặc định khi lập bản vẽ",
"Drawing & title block": "Bản vẽ và khung tên",
"Drawing Dimension": "Kích thước bản vẽ",
"Drawing Line": "Đường bản vẽ",
"Drawing Note": "Ghi chú bản vẽ",
"Drawing defaults (not available)": "Thiết lập mặc định của bản vẽ (chưa có)",
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "Tệp bản vẽ và bộ kết xuất trang giấy chưa được triển khai. Các công cụ dự kiến hiển thị tùy chọn ở những nơi đã được định nghĩa.",
"Drawing workspace": "Không gian làm việc bản vẽ",
"Edit Drawing Table": "Sửa bảng trong bản vẽ",
"Edit Table": "Sửa bảng",
"Export Drawing": "Xuất bản vẽ",
"Finish the current operation before entering Drawing": "Hoàn tất thao tác hiện tại trước khi chuyển sang không gian Bản vẽ",
"Hole Callout": "Chú dẫn lỗ",
"Image": "Hình ảnh",
"Inherited from parent view": "Kế thừa từ hình chiếu gốc",
"Insert Image": "Chèn hình ảnh",
"Leader": "Đường dẫn chú thích",
"Leader Note": "Ghi chú có đường dẫn",
"Manage sheets (not implemented)": "Quản lý tờ (chưa triển khai)",
"Manage sheets and docking": "Quản lý tờ và vị trí gắn",
"NOTES": "GHI CHÚ",
"No drawing document": "Không có tài liệu bản vẽ",
"No drawing geometry available": "Chưa có hình học bản vẽ",
"No drawing objects": "Không có đối tượng bản vẽ",
"No drawing sheet is available yet.": "Chưa có tờ bản vẽ nào.",
"No drawing view available": "Chưa có hình chiếu bản vẽ",
"No sheet available": "Chưa có tờ",
"Not available": "Chưa có",
"Not configured": "Chưa cấu hình",
"Not implemented. These options describe the planned interface; no drawing data is changed.": "Chưa triển khai. Các tùy chọn này mô tả giao diện dự kiến; không có dữ liệu bản vẽ nào bị thay đổi.",
"Note": "Ghi chú",
"Paper & layout": "Giấy và bố cục",
"Paper colour": "Màu giấy",
"Paper colour will be available when the sheet renderer is implemented.": "Có thể chọn màu giấy khi bộ kết xuất tờ bản vẽ được triển khai.",
"Parent view": "Hình chiếu gốc",
"Parts & Tables": "Chi tiết và bảng",
"Parts List": "Danh sách chi tiết",
"Print": "In",
"Print Drawing": "In bản vẽ",
"Profile & units": "Hồ sơ và đơn vị",
"Projected View": "Hình chiếu dẫn xuất",
"RESOURCES": "TÀI NGUYÊN",
"REVISION": "LẦN SỬA ĐỔI",
"References": "Tham chiếu",
"Resources": "Tài nguyên",
"Return to Design to use this command.": "Quay lại không gian Thiết kế để dùng lệnh này.",
"Review": "Rà soát",
"Revision Table": "Bảng sửa đổi",
"Revision Tbl.": "Bảng sửa đổi",
"SOURCE": "NGUỒN",
"SYMBOLS": "KÝ HIỆU",
"Screen appearance": "Diện mạo trên màn hình",
"Section View": "Hình cắt",
"Sheet Settings": "Cài đặt tờ",
"Sheet unavailable": "Tờ không khả dụng",
"Size & orientation": "Kích thước và hướng",
"Source BOM": "Bảng kê vật tư nguồn",
"Source Flat": "Bản trải phẳng nguồn",
"Source design": "Thiết kế nguồn",
"Source properties": "Thuộc tính nguồn",
"Space": "Giãn cách",
"Space Annotations": "Giãn cách chú thích",
"Space Notes": "Giãn cách ghi chú",
"TABLES": "BẢNG",
"Text style": "Kiểu chữ",
"Title": "Tiêu đề",
"Title block": "Khung tên",
"UI preview": "Xem trước giao diện",
"Units & precision": "Đơn vị và độ chính xác",
"Update Drawing": "Cập nhật bản vẽ",
"View": "Hình chiếu"
},
"en": {
"0 objects": "0 objects",
"ASSEMBLY": "ASSEMBLY",
"Add Drawing Revision": "Add Drawing Revision",
"Add Revision": "Add Revision",
"Add sheet (not implemented)": "Add sheet (not implemented)",
"Align Annotations": "Align Annotations",
"Align Notes": "Align Notes",
"Align Views": "Align Views",
"Annotate": "Annotate",
"Apply (not implemented)": "Apply (not implemented)",
"Auto (Theme)": "Auto (Theme)",
"Balloon": "Balloon",
"Base View": "Base View",
"Border & template": "Border & template",
"Browse Drawing Resources": "Browse Drawing Resources",
"CHANGES": "CHANGES",
"Centre Mark": "Centre Mark",
"Check Drawing": "Check Drawing",
"Close drawing panel": "Close drawing panel",
"DRAWING ANNOTATE": "DRAWING ANNOTATE",
"DRAWING LAYOUT": "DRAWING LAYOUT",
"DRAWING PARTS": "DRAWING PARTS",
"DRAWING REVIEW": "DRAWING REVIEW",
"Datum": "Datum",
"Detail View": "Detail View",
"Dock at bottom": "Dock at bottom",
"Dock at top": "Dock at top",
"Dock beneath Browser": "Dock beneath Browser",
"Dock left": "Dock left",
"Dock right": "Dock right",
"Dock sheet tabs": "Dock sheet tabs",
"Drafting defaults": "Drafting defaults",
"Drawing & title block": "Drawing & title block",
"Drawing Dimension": "Drawing Dimension",
"Drawing Line": "Drawing Line",
"Drawing Note": "Drawing Note",
"Drawing defaults (not available)": "Drawing defaults (not available)",
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.",
"Drawing workspace": "Drawing workspace",
"Edit Drawing Table": "Edit Drawing Table",
"Edit Table": "Edit Table",
"Export Drawing": "Export Drawing",
"Finish the current operation before entering Drawing": "Finish the current operation before entering Drawing",
"Hole Callout": "Hole Callout",
"Image": "Image",
"Inherited from parent view": "Inherited from parent view",
"Insert Image": "Insert Image",
"Leader": "Leader",
"Leader Note": "Leader Note",
"Manage sheets (not implemented)": "Manage sheets (not implemented)",
"Manage sheets and docking": "Manage sheets and docking",
"NOTES": "NOTES",
"No drawing document": "No drawing document",
"No drawing geometry available": "No drawing geometry available",
"No drawing objects": "No drawing objects",
"No drawing sheet is available yet.": "No drawing sheet is available yet.",
"No drawing view available": "No drawing view available",
"No sheet available": "No sheet available",
"Not available": "Not available",
"Not configured": "Not configured",
"Not implemented. These options describe the planned interface; no drawing data is changed.": "Not implemented. These options describe the planned interface; no drawing data is changed.",
"Note": "Note",
"Paper & layout": "Paper & layout",
"Paper colour": "Paper colour",
"Paper colour will be available when the sheet renderer is implemented.": "Paper colour will be available when the sheet renderer is implemented.",
"Parent view": "Parent view",
"Parts & Tables": "Parts & Tables",
"Parts List": "Parts List",
"Print": "Print",
"Print Drawing": "Print Drawing",
"Profile & units": "Profile & units",
"Projected View": "Projected View",
"RESOURCES": "RESOURCES",
"REVISION": "REVISION",
"References": "References",
"Resources": "Resources",
"Return to Design to use this command.": "Return to Design to use this command.",
"Review": "Review",
"Revision Table": "Revision Table",
"Revision Tbl.": "Revision Tbl.",
"SOURCE": "SOURCE",
"SYMBOLS": "SYMBOLS",
"Screen appearance": "Screen appearance",
"Section View": "Section View",
"Sheet Settings": "Sheet Settings",
"Sheet unavailable": "Sheet unavailable",
"Size & orientation": "Size & orientation",
"Source BOM": "Source BOM",
"Source Flat": "Source Flat",
"Source design": "Source design",
"Source properties": "Source properties",
"Space": "Space",
"Space Annotations": "Space Annotations",
"Space Notes": "Space Notes",
"TABLES": "TABLES",
"Text style": "Text style",
"Title": "Title",
"Title block": "Title block",
"UI preview": "UI preview",
"Units & precision": "Units & precision",
"Update Drawing": "Update Drawing",
"View": "View"
},
"ja": {
"0 objects": "0個のオブジェクト",
"ASSEMBLY": "アセンブリ",
"Add Drawing Revision": "図面の改訂を追加",
"Add Revision": "改訂を追加",
"Add sheet (not implemented)": "シートを追加(未実装)",
"Align Annotations": "注釈を整列",
"Align Notes": "注記を整列",
"Align Views": "ビューを整列",
"Annotate": "注釈",
"Apply (not implemented)": "適用(未実装)",
"Auto (Theme)": "自動(テーマ)",
"Balloon": "バルーン",
"Base View": "基準ビュー",
"Border & template": "図枠とテンプレート",
"Browse Drawing Resources": "図面リソースを参照",
"CHANGES": "変更",
"Centre Mark": "中心マーク",
"Check Drawing": "図面をチェック",
"Close drawing panel": "図面パネルを閉じる",
"DRAWING ANNOTATE": "図面の注釈",
"DRAWING LAYOUT": "図面のレイアウト",
"DRAWING PARTS": "図面の部品",
"DRAWING REVIEW": "図面の確認",
"Datum": "データム",
"Detail View": "詳細ビュー",
"Dock at bottom": "下部にドッキング",
"Dock at top": "上部にドッキング",
"Dock beneath Browser": "ブラウザーの下にドッキング",
"Dock left": "左にドッキング",
"Dock right": "右にドッキング",
"Dock sheet tabs": "シートタブをドッキング",
"Drafting defaults": "製図の既定値",
"Drawing & title block": "図面と表題欄",
"Drawing Dimension": "図面寸法",
"Drawing Line": "図面の線分",
"Drawing Note": "図面の注記",
"Drawing defaults (not available)": "図面の既定値(利用不可)",
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "図面ファイルと用紙レンダラーは未実装です。予定されているツールでは、設定項目が定義済みの場合に表示されます。",
"Drawing workspace": "図面ワークスペース",
"Edit Drawing Table": "図面の表を編集",
"Edit Table": "表を編集",
"Export Drawing": "図面をエクスポート",
"Finish the current operation before entering Drawing": "現在の操作を完了してから図面ワークスペースに移動してください",
"Hole Callout": "穴注記",
"Image": "画像",
"Inherited from parent view": "親ビューから継承",
"Insert Image": "画像を挿入",
"Leader": "引出線",
"Leader Note": "引出線付き注記",
"Manage sheets (not implemented)": "シートを管理(未実装)",
"Manage sheets and docking": "シートとドッキングを管理",
"NOTES": "注記",
"No drawing document": "図面ドキュメントがありません",
"No drawing geometry available": "利用可能な図面ジオメトリがありません",
"No drawing objects": "図面オブジェクトがありません",
"No drawing sheet is available yet.": "図面シートはまだ利用できません。",
"No drawing view available": "利用可能な図面ビューがありません",
"No sheet available": "利用可能なシートがありません",
"Not available": "利用不可",
"Not configured": "未設定",
"Not implemented. These options describe the planned interface; no drawing data is changed.": "未実装です。これらの設定項目は予定されているインターフェースを示すもので、図面データは変更されません。",
"Note": "注記",
"Paper & layout": "用紙とレイアウト",
"Paper colour": "用紙の色",
"Paper colour will be available when the sheet renderer is implemented.": "用紙の色はシートレンダラーの実装後に設定できます。",
"Parent view": "親ビュー",
"Parts & Tables": "部品と表",
"Parts List": "部品表",
"Print": "印刷",
"Print Drawing": "図面を印刷",
"Profile & units": "製図規格と単位",
"Projected View": "投影ビュー",
"RESOURCES": "リソース",
"REVISION": "改訂",
"References": "参照",
"Resources": "リソース",
"Return to Design to use this command.": "このコマンドを使用するには、デザインワークスペースに戻ってください。",
"Review": "確認",
"Revision Table": "改訂表",
"Revision Tbl.": "改訂表",
"SOURCE": "参照元",
"SYMBOLS": "記号",
"Screen appearance": "画面表示",
"Section View": "断面ビュー",
"Sheet Settings": "シート設定",
"Sheet unavailable": "シートを利用できません",
"Size & orientation": "サイズと向き",
"Source BOM": "参照元の部品表",
"Source Flat": "参照元の展開図",
"Source design": "参照元のデザイン",
"Source properties": "参照元のプロパティ",
"Space": "等間隔",
"Space Annotations": "注釈を等間隔に",
"Space Notes": "注記を等間隔に",
"TABLES": "表",
"Text style": "文字スタイル",
"Title": "タイトル",
"Title block": "表題欄",
"UI preview": "UIプレビュー",
"Units & precision": "単位と精度",
"Update Drawing": "図面を更新",
"View": "ビュー"
},
"zh": {
"0 objects": "0 个对象",
"ASSEMBLY": "装配",
"Add Drawing Revision": "添加图纸修订",
"Add Revision": "添加修订",
"Add sheet (not implemented)": "添加图纸页(未实现)",
"Align Annotations": "对齐标注",
"Align Notes": "对齐注释",
"Align Views": "对齐视图",
"Annotate": "标注",
"Apply (not implemented)": "应用(未实现)",
"Auto (Theme)": "自动(跟随主题)",
"Balloon": "零件序号",
"Base View": "基本视图",
"Border & template": "图框与模板",
"Browse Drawing Resources": "浏览制图资源",
"CHANGES": "更改",
"Centre Mark": "中心标记",
"Check Drawing": "检查图纸",
"Close drawing panel": "关闭制图面板",
"DRAWING ANNOTATE": "图纸标注",
"DRAWING LAYOUT": "图纸布局",
"DRAWING PARTS": "图纸零件",
"DRAWING REVIEW": "图纸审阅",
"Datum": "基准",
"Detail View": "局部放大视图",
"Dock at bottom": "停靠到底部",
"Dock at top": "停靠到顶部",
"Dock beneath Browser": "停靠到浏览器下方",
"Dock left": "停靠到左侧",
"Dock right": "停靠到右侧",
"Dock sheet tabs": "停靠图纸页选项卡",
"Drafting defaults": "制图默认设置",
"Drawing & title block": "图纸与标题栏",
"Drawing Dimension": "图纸尺寸标注",
"Drawing Line": "图纸线段",
"Drawing Note": "图纸注释",
"Drawing defaults (not available)": "图纸默认设置(不可用)",
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "图纸文件和纸张渲染器尚未实现。规划中的工具会显示已定义的选项。",
"Drawing workspace": "工程图工作区",
"Edit Drawing Table": "编辑图纸表格",
"Edit Table": "编辑表格",
"Export Drawing": "导出图纸",
"Finish the current operation before entering Drawing": "请先完成当前操作,再进入工程图工作区",
"Hole Callout": "孔标注",
"Image": "图像",
"Inherited from parent view": "继承父视图",
"Insert Image": "插入图像",
"Leader": "引线",
"Leader Note": "引线注释",
"Manage sheets (not implemented)": "管理图纸页(未实现)",
"Manage sheets and docking": "管理图纸页与停靠位置",
"NOTES": "注释",
"No drawing document": "没有图纸文档",
"No drawing geometry available": "没有可用的图纸几何",
"No drawing objects": "没有图纸对象",
"No drawing sheet is available yet.": "目前尚无可用的图纸页。",
"No drawing view available": "没有可用的图纸视图",
"No sheet available": "没有可用的图纸页",
"Not available": "不可用",
"Not configured": "未配置",
"Not implemented. These options describe the planned interface; no drawing data is changed.": "尚未实现。这些选项展示规划中的界面,不会更改任何图纸数据。",
"Note": "注释",
"Paper & layout": "纸张与布局",
"Paper colour": "纸张颜色",
"Paper colour will be available when the sheet renderer is implemented.": "图纸页渲染器实现后即可设置纸张颜色。",
"Parent view": "父视图",
"Parts & Tables": "零件与表格",
"Parts List": "零件明细表",
"Print": "打印",
"Print Drawing": "打印图纸",
"Profile & units": "制图规范与单位",
"Projected View": "投影视图",
"RESOURCES": "资源",
"REVISION": "修订",
"References": "引用",
"Resources": "资源",
"Return to Design to use this command.": "请返回设计工作区使用此命令。",
"Review": "审阅",
"Revision Table": "修订表",
"Revision Tbl.": "修订表",
"SOURCE": "源设计",
"SYMBOLS": "符号",
"Screen appearance": "屏幕外观",
"Section View": "剖视图",
"Sheet Settings": "图纸页设置",
"Sheet unavailable": "图纸页不可用",
"Size & orientation": "尺寸与方向",
"Source BOM": "源设计 BOM",
"Source Flat": "源设计展开图",
"Source design": "源设计",
"Source properties": "源设计属性",
"Space": "均布",
"Space Annotations": "等间距分布标注",
"Space Notes": "等间距分布注释",
"TABLES": "表格",
"Text style": "文字样式",
"Title": "标题",
"Title block": "标题栏",
"UI preview": "界面预览",
"Units & precision": "单位与精度",
"Update Drawing": "更新图纸",
"View": "视图"
},
"de": {
"0 objects": "0 Objekte",
"ASSEMBLY": "BAUGRUPPE",
"Add Drawing Revision": "Zeichnungsrevision hinzufügen",
"Add Revision": "Revision hinzufügen",
"Add sheet (not implemented)": "Blatt hinzufügen (nicht implementiert)",
"Align Annotations": "Beschriftungen ausrichten",
"Align Notes": "Hinweise ausrichten",
"Align Views": "Ansichten ausrichten",
"Annotate": "Beschriften",
"Apply (not implemented)": "Anwenden (nicht implementiert)",
"Auto (Theme)": "Automatisch (Design)",
"Balloon": "Positionssymbol",
"Base View": "Basisansicht",
"Border & template": "Rahmen & Vorlage",
"Browse Drawing Resources": "Zeichnungsressourcen durchsuchen",
"CHANGES": "ÄNDERUNGEN",
"Centre Mark": "Mittelpunktmarkierung",
"Check Drawing": "Zeichnung prüfen",
"Close drawing panel": "Zeichnungspanel schließen",
"DRAWING ANNOTATE": "ZEICHNUNG BESCHRIFTEN",
"DRAWING LAYOUT": "ZEICHNUNGSLAYOUT",
"DRAWING PARTS": "ZEICHNUNGSTEILE",
"DRAWING REVIEW": "ZEICHNUNGSPRÜFUNG",
"Datum": "Bezug",
"Detail View": "Detailansicht",
"Dock at bottom": "Unten andocken",
"Dock at top": "Oben andocken",
"Dock beneath Browser": "Unter dem Browser andocken",
"Dock left": "Links andocken",
"Dock right": "Rechts andocken",
"Dock sheet tabs": "Blattregister andocken",
"Drafting defaults": "Vorgaben für die Zeichnungserstellung",
"Drawing & title block": "Zeichnung & Schriftfeld",
"Drawing Dimension": "Zeichnungsbemaßung",
"Drawing Line": "Zeichnungslinie",
"Drawing Note": "Zeichnungshinweis",
"Drawing defaults (not available)": "Zeichnungsvorgaben (nicht verfügbar)",
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "Zeichnungsdateien und die Papierdarstellung sind noch nicht implementiert. Geplante Werkzeuge zeigen ihre Optionen an, sofern diese bereits festgelegt sind.",
"Drawing workspace": "Zeichnungsarbeitsbereich",
"Edit Drawing Table": "Zeichnungstabelle bearbeiten",
"Edit Table": "Tabelle bearbeiten",
"Export Drawing": "Zeichnung exportieren",
"Finish the current operation before entering Drawing": "Beenden Sie den aktuellen Vorgang, bevor Sie zum Zeichnungsarbeitsbereich wechseln",
"Hole Callout": "Bohrungsangabe",
"Image": "Bild",
"Inherited from parent view": "Von der übergeordneten Ansicht übernommen",
"Insert Image": "Bild einfügen",
"Leader": "Bezugslinie",
"Leader Note": "Hinweis mit Bezugslinie",
"Manage sheets (not implemented)": "Blätter verwalten (nicht implementiert)",
"Manage sheets and docking": "Blätter und Andockpositionen verwalten",
"NOTES": "HINWEISE",
"No drawing document": "Kein Zeichnungsdokument",
"No drawing geometry available": "Keine Zeichnungsgeometrie verfügbar",
"No drawing objects": "Keine Zeichnungsobjekte",
"No drawing sheet is available yet.": "Es ist noch kein Zeichnungsblatt verfügbar.",
"No drawing view available": "Keine Zeichnungsansicht verfügbar",
"No sheet available": "Kein Blatt verfügbar",
"Not available": "Nicht verfügbar",
"Not configured": "Nicht konfiguriert",
"Not implemented. These options describe the planned interface; no drawing data is changed.": "Nicht implementiert. Diese Optionen beschreiben die geplante Oberfläche; es werden keine Zeichnungsdaten geändert.",
"Note": "Hinweis",
"Paper & layout": "Papier & Layout",
"Paper colour": "Papierfarbe",
"Paper colour will be available when the sheet renderer is implemented.": "Die Papierfarbe wird verfügbar sein, sobald die Blattdarstellung implementiert ist.",
"Parent view": "Übergeordnete Ansicht",
"Parts & Tables": "Teile & Tabellen",
"Parts List": "Teileliste",
"Print": "Drucken",
"Print Drawing": "Zeichnung drucken",
"Profile & units": "Profil & Einheiten",
"Projected View": "Projizierte Ansicht",
"RESOURCES": "RESSOURCEN",
"REVISION": "REVISION",
"References": "Referenzen",
"Resources": "Ressourcen",
"Return to Design to use this command.": "Wechseln Sie zum Design-Arbeitsbereich zurück, um diesen Befehl zu verwenden.",
"Review": "Prüfen",
"Revision Table": "Revisionstabelle",
"Revision Tbl.": "Revisionstbl.",
"SOURCE": "QUELLE",
"SYMBOLS": "SYMBOLE",
"Screen appearance": "Bildschirmdarstellung",
"Section View": "Schnittansicht",
"Sheet Settings": "Blatteinstellungen",
"Sheet unavailable": "Blatt nicht verfügbar",
"Size & orientation": "Größe & Ausrichtung",
"Source BOM": "Stückliste der Quelle",
"Source Flat": "Flachmuster der Quelle",
"Source design": "Quelldesign",
"Source properties": "Eigenschaften der Quelle",
"Space": "Abstände anpassen",
"Space Annotations": "Beschriftungsabstände anpassen",
"Space Notes": "Hinweisabstände anpassen",
"TABLES": "TABELLEN",
"Text style": "Textstil",
"Title": "Titel",
"Title block": "Schriftfeld",
"UI preview": "Oberflächenvorschau",
"Units & precision": "Einheiten & Genauigkeit",
"Update Drawing": "Zeichnung aktualisieren",
"View": "Ansicht"
},
"ko": {
"0 objects": "객체 0개",
"ASSEMBLY": "조립품",
"Add Drawing Revision": "도면 리비전 추가",
"Add Revision": "리비전 추가",
"Add sheet (not implemented)": "시트 추가 (미구현)",
"Align Annotations": "주석 정렬",
"Align Notes": "메모 정렬",
"Align Views": "뷰 정렬",
"Annotate": "주석 달기",
"Apply (not implemented)": "적용 (미구현)",
"Auto (Theme)": "자동 (테마)",
"Balloon": "항목 번호 기호",
"Base View": "기본 뷰",
"Border & template": "테두리 및 템플릿",
"Browse Drawing Resources": "도면 리소스 찾아보기",
"CHANGES": "변경 사항",
"Centre Mark": "중심 표시",
"Check Drawing": "도면 검사",
"Close drawing panel": "도면 패널 닫기",
"DRAWING ANNOTATE": "도면 주석",
"DRAWING LAYOUT": "도면 배치",
"DRAWING PARTS": "도면 부품",
"DRAWING REVIEW": "도면 검토",
"Datum": "기준",
"Detail View": "상세 뷰",
"Dock at bottom": "아래쪽에 고정",
"Dock at top": "위쪽에 고정",
"Dock beneath Browser": "브라우저 아래에 고정",
"Dock left": "왼쪽에 고정",
"Dock right": "오른쪽에 고정",
"Dock sheet tabs": "시트 탭 고정",
"Drafting defaults": "도면 작성 기본 설정",
"Drawing & title block": "도면 및 표제란",
"Drawing Dimension": "도면 치수",
"Drawing Line": "도면 선",
"Drawing Note": "도면 메모",
"Drawing defaults (not available)": "도면 기본 설정 (사용 불가)",
"Drawing files and the paper renderer are not implemented. Planned tools show their options where defined.": "도면 파일과 용지 렌더러는 아직 구현되지 않았습니다. 예정된 도구는 정의된 옵션이 있는 경우 해당 옵션을 표시합니다.",
"Drawing workspace": "도면 작업 공간",
"Edit Drawing Table": "도면 표 편집",
"Edit Table": "표 편집",
"Export Drawing": "도면 내보내기",
"Finish the current operation before entering Drawing": "도면 작업 공간으로 전환하기 전에 현재 작업을 완료하세요",
"Hole Callout": "구멍 주석",
"Image": "이미지",
"Inherited from parent view": "상위 뷰에서 상속됨",
"Insert Image": "이미지 삽입",
"Leader": "지시선",
"Leader Note": "지시선 메모",
"Manage sheets (not implemented)": "시트 관리 (미구현)",
"Manage sheets and docking": "시트 및 고정 위치 관리",
"NOTES": "메모",
"No drawing document": "도면 문서 없음",
"No drawing geometry available": "사용 가능한 도면 형상 없음",
"No drawing objects": "도면 객체 없음",
"No drawing sheet is available yet.": "아직 사용 가능한 도면 시트가 없습니다.",
"No drawing view available": "사용 가능한 도면 뷰 없음",
"No sheet available": "사용 가능한 시트 없음",
"Not available": "사용 불가",
"Not configured": "설정되지 않음",
"Not implemented. These options describe the planned interface; no drawing data is changed.": "아직 구현되지 않았습니다. 이 옵션은 예정된 인터페이스를 설명하며 도면 데이터를 변경하지 않습니다.",
"Note": "메모",
"Paper & layout": "용지 및 배치",
"Paper colour": "용지 색상",
"Paper colour will be available when the sheet renderer is implemented.": "시트 렌더러가 구현되면 용지 색상을 설정할 수 있습니다.",
"Parent view": "상위 뷰",
"Parts & Tables": "부품 및 표",
"Parts List": "부품 목록",
"Print": "인쇄",
"Print Drawing": "도면 인쇄",
"Profile & units": "프로파일 및 단위",
"Projected View": "투영 뷰",
"RESOURCES": "리소스",
"REVISION": "리비전",
"References": "참조",
"Resources": "리소스",
"Return to Design to use this command.": "이 명령을 사용하려면 디자인 작업 공간으로 돌아가세요.",
"Review": "검토",
"Revision Table": "리비전 표",
"Revision Tbl.": "리비전 표",
"SOURCE": "원본",
"SYMBOLS": "기호",
"Screen appearance": "화면 모양",
"Section View": "단면 뷰",
"Sheet Settings": "시트 설정",
"Sheet unavailable": "시트 사용 불가",
"Size & orientation": "크기 및 방향",
"Source BOM": "원본 자재 명세서",
"Source Flat": "원본 전개도",
"Source design": "원본 디자인",
"Source properties": "원본 속성",
"Space": "간격 조정",
"Space Annotations": "주석 간격 조정",
"Space Notes": "메모 간격 조정",
"TABLES": "표",
"Text style": "텍스트 스타일",
"Title": "제목",
"Title block": "표제란",
"UI preview": "인터페이스 미리보기",
"Units & precision": "단위 및 정밀도",
"Update Drawing": "도면 업데이트",
"View": "뷰"
}
}
@@ -1,119 +0,0 @@
{
"ar": {
"cmd.assembly_bom_item_set": "تغيير رقم البند",
"cmd.assembly_bom_renumber": "إعادة ترقيم البنود",
"cmd.drawing_balloon_add": "إضافة بالون",
"cmd.drawing_balloon_edit": "تعديل البالون",
"cmd.drawing_balloon_inspect": "فحص البالون",
"cmd.drawing_close": "إغلاق اللوحة",
"cmd.drawing_create": "إنشاء لوحة",
"cmd.drawing_delete": "حذف اللوحة",
"cmd.drawing_dimension_add": "إضافة بُعد إلى اللوحة",
"cmd.drawing_dimension_edit": "تعديل بُعد اللوحة",
"cmd.drawing_dimension_inspect": "فحص بُعد اللوحة",
"cmd.drawing_export": "تصدير الورقة",
"cmd.drawing_inspect": "فحص اللوحة",
"cmd.drawing_list": "سرد اللوحات",
"cmd.drawing_open": "فتح اللوحة",
"cmd.drawing_properties": "خصائص اللوحة",
"cmd.drawing_properties_remove": "إزالة خاصية اللوحة",
"cmd.drawing_properties_set": "تغيير خصائص اللوحة",
"cmd.drawing_revision_add": "إضافة مراجعة",
"cmd.drawing_revision_delete": "حذف المراجعة",
"cmd.drawing_revision_edit": "تعديل المراجعة",
"cmd.drawing_scene": "رسم الورقة",
"cmd.drawing_set": "إعدادات اللوحة",
"cmd.drawing_sheet_add": "إضافة ورقة",
"cmd.drawing_sheet_delete": "حذف الورقة",
"cmd.drawing_sheet_reorder": "إعادة ترتيب الأوراق",
"cmd.drawing_sheet_set": "إعدادات الورقة",
"cmd.drawing_view_copy_to_sheet": "نسخ المسقط إلى ورقة",
"cmd.drawing_view_edit": "تعديل مسقط اللوحة",
"cmd.drawing_view_inspect": "فحص مسقط اللوحة",
"cmd.drawing_view_move_to_sheet": "نقل المسقط إلى ورقة",
"cmd.drawing_view_snapshot": "لقطة مسقط اللوحة",
"cmd.occurrence_bom_type": "نوع قائمة المواد",
"prefs.drawings": "اللوحات",
"ui.first_angle": "الزاوية الأولى",
"ui.projection_of_new_drawings": "إسقاط اللوحات الجديدة",
"ui.third_angle": "الزاوية الثالثة"
},
"he": {
"cmd.assembly_bom_item_set": "שינוי מספר פריט",
"cmd.assembly_bom_renumber": "מספור מחדש של פריטים",
"cmd.drawing_balloon_add": "הוספת בלון",
"cmd.drawing_balloon_edit": "עריכת בלון",
"cmd.drawing_balloon_inspect": "בדיקת בלון",
"cmd.drawing_close": "סגירת שרטוט",
"cmd.drawing_create": "יצירת שרטוט",
"cmd.drawing_delete": "מחיקת שרטוט",
"cmd.drawing_dimension_add": "הוספת מידה לשרטוט",
"cmd.drawing_dimension_edit": "עריכת מידת שרטוט",
"cmd.drawing_dimension_inspect": "בדיקת מידת שרטוט",
"cmd.drawing_export": "ייצוא גיליון",
"cmd.drawing_inspect": "בדיקת שרטוט",
"cmd.drawing_list": "רשימת שרטוטים",
"cmd.drawing_open": "פתיחת שרטוט",
"cmd.drawing_properties": "מאפייני שרטוט",
"cmd.drawing_properties_remove": "הסרת מאפיין שרטוט",
"cmd.drawing_properties_set": "שינוי מאפייני שרטוט",
"cmd.drawing_revision_add": "הוספת גרסה",
"cmd.drawing_revision_delete": "מחיקת גרסה",
"cmd.drawing_revision_edit": "עריכת גרסה",
"cmd.drawing_scene": "ציור גיליון",
"cmd.drawing_set": "הגדרות שרטוט",
"cmd.drawing_sheet_add": "הוספת גיליון",
"cmd.drawing_sheet_delete": "מחיקת גיליון",
"cmd.drawing_sheet_reorder": "סידור גיליונות מחדש",
"cmd.drawing_sheet_set": "הגדרות גיליון",
"cmd.drawing_view_copy_to_sheet": "העתקת מבט לגיליון",
"cmd.drawing_view_edit": "עריכת מבט שרטוט",
"cmd.drawing_view_inspect": "בדיקת מבט שרטוט",
"cmd.drawing_view_move_to_sheet": "העברת מבט לגיליון",
"cmd.drawing_view_snapshot": "תמונת מבט שרטוט",
"cmd.occurrence_bom_type": "סוג ברשימת החלקים",
"prefs.drawings": "שרטוטים",
"ui.first_angle": "זווית ראשונה",
"ui.projection_of_new_drawings": "היטל של שרטוטים חדשים",
"ui.third_angle": "זווית שלישית"
},
"vi": {
"cmd.assembly_bom_item_set": "Đổi số hạng mục",
"cmd.assembly_bom_renumber": "Đánh số lại hạng mục",
"cmd.drawing_balloon_add": "Thêm bóng chú thích",
"cmd.drawing_balloon_edit": "Sửa bóng chú thích",
"cmd.drawing_balloon_inspect": "Kiểm tra bóng chú thích",
"cmd.drawing_close": "Đóng bản vẽ",
"cmd.drawing_create": "Tạo bản vẽ",
"cmd.drawing_delete": "Xóa bản vẽ",
"cmd.drawing_dimension_add": "Thêm kích thước bản vẽ",
"cmd.drawing_dimension_edit": "Sửa kích thước bản vẽ",
"cmd.drawing_dimension_inspect": "Kiểm tra kích thước bản vẽ",
"cmd.drawing_export": "Xuất tờ",
"cmd.drawing_inspect": "Kiểm tra bản vẽ",
"cmd.drawing_list": "Liệt kê bản vẽ",
"cmd.drawing_open": "Mở bản vẽ",
"cmd.drawing_properties": "Thuộc tính bản vẽ",
"cmd.drawing_properties_remove": "Xóa thuộc tính bản vẽ",
"cmd.drawing_properties_set": "Thay đổi thuộc tính bản vẽ",
"cmd.drawing_revision_add": "Thêm lần sửa đổi",
"cmd.drawing_revision_delete": "Xóa lần sửa đổi",
"cmd.drawing_revision_edit": "Sửa lần sửa đổi",
"cmd.drawing_scene": "Vẽ tờ",
"cmd.drawing_set": "Cài đặt bản vẽ",
"cmd.drawing_sheet_add": "Thêm tờ",
"cmd.drawing_sheet_delete": "Xóa tờ",
"cmd.drawing_sheet_reorder": "Sắp xếp lại các tờ",
"cmd.drawing_sheet_set": "Cài đặt tờ",
"cmd.drawing_view_copy_to_sheet": "Sao chép hình chiếu sang tờ",
"cmd.drawing_view_edit": "Sửa hình chiếu bản vẽ",
"cmd.drawing_view_inspect": "Kiểm tra hình chiếu bản vẽ",
"cmd.drawing_view_move_to_sheet": "Chuyển hình chiếu sang tờ",
"cmd.drawing_view_snapshot": "Ảnh chụp hình chiếu bản vẽ",
"cmd.occurrence_bom_type": "Loại trong bảng kê vật tư",
"prefs.drawings": "Bản vẽ",
"ui.first_angle": "Góc chiếu thứ nhất",
"ui.projection_of_new_drawings": "Phép chiếu của bản vẽ mới",
"ui.third_angle": "Góc chiếu thứ ba"
}
}
@@ -0,0 +1,7 @@
{
"en": {
"document.newer_file": "Saved by a newer SolidCraft (format {format}, this version reads up to {reads}). If something's missing, update.",
"document.newer_file_change": "Saved by a newer SolidCraft (format {format} with `{change}`, which this version doesn't know). If something's missing, update.",
"document.newer_file_not_read": "Not read: {what}. Saving keeps it."
}
}
+2
View File
@@ -1986,6 +1986,8 @@
"prefs.shortcuts": "Skróty",
"prefs.single_surface_analysis": "Używaj jednej analizy powierzchni",
"prefs.single_surface_analysis_help": "Gdy wyłączone, cieniowanie powierzchni i Ciągłość mogą być włączone jednocześnie. Włączenie zachowuje ostatnio uruchomioną lub zmienioną analizę.",
"prefs.sketch.ellipse_axes": "Rysuj elipsy z osiami",
"prefs.sketch.ellipse_axes_note": "Oś wielka i mała jako linie konstrukcyjne, aby można było wymiarować półosie i osie. Wyłączone: sama elipsa, a D na niej daje promień małej półosi, gdziekolwiek przeciągnąć wartość, rysowany i oznaczony R jak dotąd.",
"prefs.sketch_align_points": "Wyrównuj do innych punktów szkicu podczas rysowania",
"prefs.sketch_align_points_note": "Podczas rysowania punkt wyrównuje się jednocześnie poziomo z jednym punktem szkicu i pionowo z innym, z kropkowaną linią pomocniczą, a kliknięcie zachowuje oba wyrównania. Wyłączone: tylko z poprzednim punktem, po jednej osi.",
"prefs.sketch_conflict_colour": "Geometria w konflikcie w kolorze konfliktu",
+4 -4
View File
@@ -1722,7 +1722,7 @@
"help.view.tool": "Закрепляет инструмент камеры (орбиту, панорамирование или масштаб), так что перетаскивание левой кнопкой мыши двигает вид, пока вы его не отключите. Используйте на трекпаде или без средней кнопки мыши.",
"help.view.visual_style": "Переключает способ отрисовки модели: тонированная с рёбрами или без них, каркас или со скрытыми рёбрами. Используйте каркас, чтобы заглянуть внутрь, а тонированный вид — для чистого вида.",
"history.sketch_under_constrained": "Недоопределён (степеней свободы: {count})",
"history.sketch_under_constrained_one": "Недоопределён (степеней свободы: {count})",
"history.sketch_under_constrained_one": "Недоопределён ({count} степень свободы)",
"hole_presets.confirm_delete": "Подтвердить удаление",
"hole_presets.delete": "Удалить",
"hole_presets.delete_prompt": "Удалить этот личный пресет? Существующие отверстия не изменятся.",
@@ -2135,7 +2135,7 @@
"prefs.applies_on_restart": "Применяется после перезапуска",
"prefs.assistant": "Ассистент",
"prefs.auto_operation": "Выбирать операцию автоматически",
"prefs.auto_operation_note": "Окна операций сами выбирают «Объединить», «Новое тело» или «Вырезать» в зависимости от того, чего касается новый материал, пока вы не выберете операцию вручную. Выкл.: окна сохраняют ту операцию, с которой открылись.",
"prefs.auto_operation_note": "Выбор «Объединить», «Новое тело» или «Вырезать» по тому, чего касается материал.",
"prefs.axes": "Оси",
"prefs.axis": "Ось",
"prefs.bodies_folders": "Папки тел",
@@ -2374,7 +2374,7 @@
"prefs.stand_up_unknown.ask": "Спрашивать в диалоговом окне",
"prefs.stand_up_unknown.note": "Показывать уведомление",
"prefs.stand_up_unknown.off": "Не предлагать",
"prefs.stand_up_unknown_note": "Как делается предложение, когда в заголовке файла не указана программа, о которой известно, что она сохраняет с осью Y вверх. Уведомление показывается, только если файл, судя по всему, лежит на спине; «Спрашивать в диалоговом окне» спрашивает о каждом таком файле, как раньше.",
"prefs.stand_up_unknown_note": "Как делается предложение, когда в файле не указана известная программа.",
"prefs.status_bar": "Строка состояния",
"prefs.status_bar_note": "Тонкая полоса вдоль нижнего края окна показывает, что изменяется, состояние открытого эскиза его цветом и единицы дизайна; щелчок по единицам изменяет их. Состояние эскиза при этом больше не показывается в углу вида.",
"prefs.status_done": "Готово",
@@ -3878,7 +3878,7 @@
"status.navigation": "Назначение кнопок мыши: {mapping}",
"status.navigation_tip": "Как мышь двигает вид. Щёлкните, чтобы выбрать другое назначение",
"status.under_constrained": "Недоопределён (степеней свободы: {count})",
"status.under_constrained_one": "Недоопределён (степеней свободы: {count})",
"status.under_constrained_one": "Недоопределён ({count} степень свободы)",
"status.units_tip": "Единицы дизайна. Щёлкните, чтобы изменить",
"the angle must be a number": "угол должен быть числом",
"the curves must be in one sketch": "кривые должны быть в одном эскизе",
+490 -78
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -85,6 +85,7 @@
"Bottom Right": "Dưới cùng bên phải",
"Boundary": "Biên dạng bao",
"Bounding box": "Hộp bao ngoài",
"Bridge": "Cầu nối",
"CHECK": "KIỂM TRA",
"COMPONENTS": "CHI TIẾT",
"CONFIGURATIONS": "CẤU HÌNH",
@@ -1296,6 +1297,7 @@
"cmd.timeline_stop": "Dừng lịch sử",
"cmd.timeline_suppress": "Tạm nén tính năng",
"cmd.ui.assistant": "Bật/tắt trợ lý",
"cmd.view.navigator": "Khối điều hướng góc nhìn tùy chỉnh",
"cmd.view.render_browser": "Trình duyệt kết xuất",
"cmd.view.render_dock": "Di chuyển neo kết xuất",
"cmd.view.render_focus": "Chế độ xem tập trung kết xuất",
@@ -1445,6 +1447,9 @@
"each rail's curves must be in one sketch": "các đường cong của mỗi đường dẫn hướng phải nằm trong cùng một phác thảo",
"feature computation ended without a result": "việc tính toán đặc trưng kết thúc mà không có kết quả",
"file.open.drawing_filter": "Thiết kế, tệp CAD, lưới đa giác và bản vẽ",
"file.open.portal_fallback.no_portal_service": "Bộ chọn tệp đã mở không phải của riêng desktop: dịch vụ chọn tệp của desktop không khả dụng.",
"file.open.portal_fallback.no_session_bus": "Bộ chọn tệp đã mở không phải của riêng desktop: không có bus phiên D-Bus.",
"file.open.portal_fallback.xwayland": "Bộ chọn tệp đã mở không phải của riêng desktop: dưới XWayland, portal từ chối cửa sổ cha.",
"file_load.built": "Đã dựng được: {built} trên {total} tính năng.",
"file_load.converting": "Đang chuyển đổi khối đặc {done} trên {total}",
"file_load.inserting": "Đang chèn hình học",
@@ -2554,6 +2559,8 @@
"prefs.section_hatch_by_part": "Theo chi tiết",
"prefs.section_hatch_one": "Một kiểu",
"prefs.select": "Chọn",
"prefs.select_hidden_edges": "Chọn cạnh bị che trong chế độ tô bóng có cạnh ẩn",
"prefs.select_hidden_edges_help": "Cho phép chọn cạnh xuyên qua bề mặt khi dùng chế độ tô bóng có cạnh ẩn, kể cả cho lệnh Fillet. Tắt để giữ cách chọn của v0.30.1. Các khối đã ẩn vẫn không thể chọn.",
"prefs.select_one_item": "Chọn một mục",
"prefs.select_other_hidden": "Chọn hình học ẩn bằng Chọn đối tượng khác",
"prefs.select_other_hidden_help": "Bật: bao gồm các khối, bản phác thảo ẩn và cạnh bị che gần con trỏ. Tắt: dùng danh sách ban đầu chỉ gồm hình học đang hiển thị ngay dưới con trỏ.",
@@ -2578,6 +2585,8 @@
"prefs.sketch.click_selects": "Nhấp để chọn",
"prefs.sketch.conflict_colour": "Màu xung đột",
"prefs.sketch.drawing": "Vẽ",
"prefs.sketch.ellipse_axes": "Vẽ hình elip kèm các trục",
"prefs.sketch.ellipse_axes_note": "Trục lớn và trục nhỏ dưới dạng đường gióng dựng hình, để có thể ghi kích thước bán trục và trục. Tắt: chỉ vẽ hình elip, và D trên nó cho bán kính nhỏ dù kéo giá trị đến đâu, được vẽ và ghi nhãn R như trước.",
"prefs.sketch.exit_keep": "Giữ nguyên",
"prefs.sketch.exit_previous": "Trở về góc nhìn trước đó",
"prefs.sketch.exit_unless_rotated": "Trừ khi đã xoay",
@@ -4168,6 +4177,32 @@
"sketch.dof_left": "Còn {count} bậc tự do",
"sketch.dof_left_one": "Còn {count} bậc tự do",
"sketch.draw_curves": "Vẽ dưới dạng {linetype}",
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
"sketch.error.nothing_to_break": "nothing to break here",
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
"sketch.hint.break": "Break: click a curve where it should split",
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
"sketch.hint.fallback": "Click in the sketch",
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
"sketch.hint.open_done": "Open done",
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
"sketch.hint.slot": "Slot: click both ends, then the width",
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
"sketch.make_selection": "Chuyển đổi vùng chọn sang {linetype}",
"sketch.open_ends": "Đầu hở: {count}",
"sketch.status_conflict": "ràng buộc xung đột",
@@ -5017,6 +5052,7 @@
"ui.heading_construct": "DỰNG HÌNH",
"ui.heading_create": "TẠO MỚI",
"ui.heading_create_sketch": "TẠO PHÁC THẢO",
"ui.heading_custom_view_navigator": "KHỐI ĐIỀU HƯỚNG GÓC NHÌN TÙY CHỈNH",
"ui.heading_cylinder": "HÌNH TRỤ",
"ui.heading_design": "THIẾT KẾ",
"ui.heading_draft": "ĐỘ DỐC THOÁT KHUÔN",
@@ -5612,6 +5648,14 @@
"values must be numbers or expressions": "các giá trị phải là số hoặc biểu thức",
"view.auto_ortho_faces": "Phối cảnh khi xoay quỹ đạo, trực giao khi nhìn trực diện mặt",
"view.render_inspector": "Bảng thuộc tính kết xuất",
"view_gizmo.arrows": "Mũi tên xoay",
"view_gizmo.home": "Nút góc nhìn mặc định (Home)",
"view_gizmo.no_such_body": "Thiết kế này không có thân khối nào mang tên đó.",
"view_gizmo.note": "Chọn một thân khối trong khung nhìn. Thân khối được lưu cùng tùy chọn và hồ sơ cài đặt của bạn, và hiển thị trong mọi thiết kế. Nhấp vào một mặt của nó để nhìn thẳng vào mặt đó.",
"view_gizmo.texture": "Kết cấu",
"view_gizmo.themed": "Theo chủ đề",
"view_gizmo.too_detailed": "Quá chi tiết cho khối điều hướng góc nhìn (hơn 10.000 tam giác)",
"view_gizmo.too_thin": "Quá dài và mảnh cho khối điều hướng góc nhìn: cạnh mỏng nhất phải ít nhất bằng một phần năm cạnh dài nhất",
"web.files.insert_drawings_hint": "Tệp CAD, lưới đa giác, DXF hoặc SVG vào thiết kế đang mở",
"web.files.insert_stored_drawings_hint": "Vào thiết kế đang mở (tệp CAD, lưới đa giác, DXF, SVG)",
"web.open_full": "Mở trong SolidCraft",
+44 -37
View File
@@ -85,6 +85,7 @@
"Bottom Right": "右下",
"Boundary": "边界",
"Bounding box": "边界框",
"Bridge": "桥接",
"CHECK": "检查",
"COMPONENTS": "组件",
"CONFIGURATIONS": "配置",
@@ -833,8 +834,6 @@
"cmd.appearance_pick_up": "拾取外观",
"cmd.appearance_replace": "替换外观",
"cmd.assembly_bom": "物料清单",
"cmd.assembly_bom_item_set": "更改项目编号",
"cmd.assembly_bom_renumber": "重新编号项目",
"cmd.assembly_mass_properties": "质量属性",
"cmd.body.print_face": "打印底面",
"cmd.body_rename": "重命名实体",
@@ -934,36 +933,6 @@
"cmd.document_metadata_set": "更改设计属性",
"cmd.document_revision": "设计版本号",
"cmd.document_units": "更改设计单位",
"cmd.drawing_balloon_add": "添加零件序号",
"cmd.drawing_balloon_edit": "编辑零件序号",
"cmd.drawing_balloon_inspect": "检查零件序号",
"cmd.drawing_close": "关闭绘图",
"cmd.drawing_create": "创建绘图",
"cmd.drawing_delete": "删除绘图",
"cmd.drawing_dimension_add": "添加绘图尺寸",
"cmd.drawing_dimension_edit": "编辑绘图尺寸",
"cmd.drawing_dimension_inspect": "检查绘图尺寸",
"cmd.drawing_export": "导出图纸",
"cmd.drawing_inspect": "检查绘图",
"cmd.drawing_list": "列出绘图",
"cmd.drawing_open": "打开绘图",
"cmd.drawing_properties": "绘图属性",
"cmd.drawing_properties_remove": "删除绘图属性",
"cmd.drawing_properties_set": "更改绘图属性",
"cmd.drawing_revision_add": "添加版本",
"cmd.drawing_revision_delete": "删除版本",
"cmd.drawing_revision_edit": "编辑版本",
"cmd.drawing_scene": "绘制图纸",
"cmd.drawing_set": "绘图设置",
"cmd.drawing_sheet_add": "添加图纸",
"cmd.drawing_sheet_delete": "删除图纸",
"cmd.drawing_sheet_reorder": "重新排列图纸",
"cmd.drawing_sheet_set": "图纸设置",
"cmd.drawing_view_copy_to_sheet": "将视图复制到图纸",
"cmd.drawing_view_edit": "编辑绘图视图",
"cmd.drawing_view_inspect": "检查绘图视图",
"cmd.drawing_view_move_to_sheet": "将视图移至图纸",
"cmd.drawing_view_snapshot": "绘图视图快照",
"cmd.edit_checkpoint": "检查点",
"cmd.edit_repeat": "重复上一个命令",
"cmd.edit_restore_checkpoint": "恢复检查点",
@@ -1061,7 +1030,6 @@
"cmd.motion_trace_edit": "编辑运动轨迹",
"cmd.motion_trace_keep": "保留运动轨迹",
"cmd.occurrence_align": "对齐实例",
"cmd.occurrence_bom_type": "物料清单类型",
"cmd.occurrence_copy": "粘贴(实例)",
"cmd.occurrence_drag": "拖动实例",
"cmd.occurrence_ground": "固定",
@@ -1329,6 +1297,7 @@
"cmd.timeline_stop": "停止历史播放",
"cmd.timeline_suppress": "抑制特征",
"cmd.ui.assistant": "切换助手显示",
"cmd.view.navigator": "自定义视图导航器",
"cmd.view.render_browser": "渲染浏览器",
"cmd.view.render_dock": "移动渲染停靠面板",
"cmd.view.render_focus": "渲染专注视图",
@@ -1478,6 +1447,9 @@
"each rail's curves must be in one sketch": "每条导轨的曲线必须位于同一个草图中",
"feature computation ended without a result": "特征计算结束时没有返回结果",
"file.open.drawing_filter": "设计、CAD 文件、网格和图纸",
"file.open.portal_fallback.no_portal_service": "打开的文件选择器并非桌面自带:桌面的文件选择服务不可用。",
"file.open.portal_fallback.no_session_bus": "打开的文件选择器并非桌面自带:没有 D-Bus 会话总线。",
"file.open.portal_fallback.xwayland": "打开的文件选择器并非桌面自带:在 XWayland 下,门户会拒绝父窗口。",
"file_load.built": "已构建:{built}/{total} 个特征。",
"file_load.converting": "正在转换实体 {done}/{total}",
"file_load.inserting": "正在插入几何体",
@@ -2378,7 +2350,6 @@
"prefs.dimension_values_view": "在视图中",
"prefs.dimension_values_window": "在窗口中",
"prefs.display": "显示",
"prefs.drawings": "绘图",
"prefs.edges": "边缘与着色",
"prefs.extend": "扩展",
"prefs.extra_navigation": "在空白处按住Ctrl并用鼠标左键拖动可旋转;按住空格键可平移。",
@@ -2588,6 +2559,8 @@
"prefs.section_hatch_by_part": "按零件",
"prefs.section_hatch_one": "单一样式",
"prefs.select": "选择",
"prefs.select_hidden_edges": "在带隐藏边线的着色视图中选择隐藏边",
"prefs.select_hidden_edges_help": "在带隐藏边线的着色视图中,可穿过面选择边并用于圆角。关闭时保留v0.30.1的选择行为。隐藏的实体仍不可选择。",
"prefs.select_one_item": "选择一个项目",
"prefs.select_other_hidden": "使用“选择其他”选择隐藏几何体",
"prefs.select_other_hidden_help": "开启:包括指针附近的隐藏实体、草图和被遮挡的边。关闭:恢复原来的列表,仅包含指针正下方的可见几何体。",
@@ -2612,6 +2585,8 @@
"prefs.sketch.click_selects": "单击选择",
"prefs.sketch.conflict_colour": "冲突颜色",
"prefs.sketch.drawing": "绘制",
"prefs.sketch.ellipse_axes": "绘制椭圆时带上轴线",
"prefs.sketch.ellipse_axes_note": "将长轴和短轴绘制为构造线,以便标注半轴和轴。关闭:仅绘制椭圆,对其按 D 得到短半径,无论将数值拖到何处,都像以前一样绘制并标注 R。",
"prefs.sketch.exit_keep": "保持",
"prefs.sketch.exit_previous": "返回之前的视图",
"prefs.sketch.exit_unless_rotated": "除非已旋转",
@@ -4202,6 +4177,32 @@
"sketch.dof_left": "剩余 {count} 个自由度",
"sketch.dof_left_one": "剩余 {count} 个自由度",
"sketch.draw_curves": "绘制为 {linetype}",
"sketch.error.closed_curve_two_crossings": "a closed curve needs two crossings to trim",
"sketch.error.nothing_to_break": "nothing to break here",
"sketch.error.offset_fillet_chamfer": "`{id}` is an offset, and its corners follow its source curves: round or cut the source corners before offsetting, or break the offset association first",
"sketch.error.offset_trim": "`{id}` is an offset, and its ends follow its source curves: edit the source curves instead, or break the offset association first",
"sketch.hint.arc.center_point": "Arc: click center and start, go round either way and click the end, or type a signed sweep",
"sketch.hint.arc.three_point": "Arc: click start, end, then a point on the arc",
"sketch.hint.arc3d": "3D Arc: click start, end, then a point on the arc (points may leave the sketch plane)",
"sketch.hint.break": "Break: click a curve where it should split",
"sketch.hint.circle.center": "Circle: click the center, then a point on the circle",
"sketch.hint.constraint.generic": "Constraint: pick the sketch entities",
"sketch.hint.constraint.horizontal_vertical": "Horizontal/Vertical: pick a line, two points, or a point and a line; hold Shift to pick a line midpoint",
"sketch.hint.constraint.midpoint": "MidPoint: pick a point and a line, or two lines to center the second on the first",
"sketch.hint.dimension": "Dimension: pick one or two entities and type the value (or click to place it first); click a dimension to edit it; Enter applies",
"sketch.hint.extend": "Extend: click a curve near the end to extend (Shift: click two curves to extend both to where they meet)",
"sketch.hint.fallback": "Click in the sketch",
"sketch.hint.line": "Line: click points; right-click or Esc to finish",
"sketch.hint.line3d": "3D Line: click world points; right-click or Esc to finish (points may leave the sketch plane)",
"sketch.hint.open_done": "Open done",
"sketch.hint.point3d": "3D Point: click where a 3D point goes",
"sketch.hint.rectangle.center": "Center rectangle: click the center, then a corner",
"sketch.hint.rectangle.rounded": "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)",
"sketch.hint.rectangle.three_point": "3-point rectangle: click two points of one edge, then the width",
"sketch.hint.rectangle.two_point": "Rectangle: click two opposite corners",
"sketch.hint.slot": "Slot: click both ends, then the width",
"sketch.hint.spline3d": "3D Spline: click fit points in 3D; right-click or Esc to finish",
"sketch.hint.trim": "Trim: click the piece of a curve to remove, or drag across the pieces to remove",
"sketch.make_selection": "将选择转换为 {linetype}",
"sketch.open_ends": "开放 {count}",
"sketch.status_conflict": "约束冲突",
@@ -5015,7 +5016,6 @@
"ui.fine": "精细",
"ui.finest": "最精细",
"ui.finish_sketch": "完成草图",
"ui.first_angle": "第一角投影",
"ui.fit": "适合",
"ui.fix_unfix": "固定/取消固定",
"ui.flat": "平面",
@@ -5052,6 +5052,7 @@
"ui.heading_construct": "构造",
"ui.heading_create": "创建",
"ui.heading_create_sketch": "创建草图",
"ui.heading_custom_view_navigator": "自定义视图导航器",
"ui.heading_cylinder": "圆柱体",
"ui.heading_design": "设计",
"ui.heading_draft": "拔模",
@@ -5320,7 +5321,6 @@
"ui.profiles": "轮廓",
"ui.project": "投影",
"ui.project_face_edges_sketch": "将面的边投影到新草图",
"ui.projection_of_new_drawings": "新绘图的投影法",
"ui.properties": "属性",
"ui.properties.copy_matrix": "复制矩阵",
"ui.properties.first_moments": "一阶矩",
@@ -5506,7 +5506,6 @@
"ui.text": "文本",
"ui.theme": "主题",
"ui.thickness": "厚度",
"ui.third_angle": "第三角投影",
"ui.thread": "螺纹",
"ui.tie_ends": "约束",
"ui.tie_ends_tip": "约束全部未约束端点",
@@ -5649,6 +5648,14 @@
"values must be numbers or expressions": "值必须是数字或表达式",
"view.auto_ortho_faces": "旋转时透视,正对表面时正交",
"view.render_inspector": "渲染检查器",
"view_gizmo.arrows": "旋转箭头",
"view_gizmo.home": "主页按钮",
"view_gizmo.no_such_body": "此设计中没有该名称的实体。",
"view_gizmo.note": "在视图中选择一个实体。它随首选项和设置配置文件保存,并在每个设计中显示。单击它的一个面即可正视该面。",
"view_gizmo.texture": "纹理",
"view_gizmo.themed": "主题",
"view_gizmo.too_detailed": "对视图导航器而言过于精细(超过 10,000 个三角形)",
"view_gizmo.too_thin": "对视图导航器而言过于细长:最薄的一边至少要有最长一边的五分之一",
"web.files.insert_drawings_hint": "将 CAD 文件、网格、DXF 或 SVG 插入当前设计",
"web.files.insert_stored_drawings_hint": "插入当前设计(CAD 文件、网格、DXF、SVG)",
"web.open_full": "在 SolidCraft 中打开",
+667
View File
@@ -0,0 +1,667 @@
{
"order": 260,
"provenance": "Original SolidCraft stone-grey palette and semantic icon-role mapping",
"format": "solidcraft-theme/1",
"name": "Onyx",
"base": "dark",
"colors": {
"accent": "#9eafbd",
"accent_soft": "#3b4852",
"agent_busy": "#9eafbd",
"agent_ok": "#a5b9a5",
"app_bar": "#1c1e20",
"app_bar_hover": "#dededc16",
"app_bar_hover_button": "#dededc1c",
"app_bar_hover_strong": "#dededc28",
"app_bar_hover_subtle": "#dededc0c",
"app_bar_separator": "#383c3f",
"app_bar_tab_active": "#303235",
"app_bar_text": "#dededc",
"axis_x": "#d7938f",
"axis_y": "#a5b9a5",
"axis_z": "#98b2c8",
"bend_down": "#b8a7c3",
"bend_up": "#a5b9a5",
"body": "#93989c",
"body_edge": "#60666b",
"body_selected": "#3b4852",
"border": "#3d4145",
"box_window": "#9eafbd",
"chip": "#758b9b",
"construction_geom": "#b9a78e",
"construction_plane": "#b9a78e24",
"continuity": [
"#a5b9a5",
"#98b2c8",
"#c7b28b",
"#b8a7c3"
],
"control_border": "#4a4f54",
"control_fill": "#303235",
"cube_border": "#50565b",
"cube_face": "#34373a",
"cube_hover": "#53616c",
"cube_text": "#dededc",
"dialog_bg": "#2b2d2f",
"dialog_body": "#2b2d2f",
"dialog_border": "#3d4145",
"dialog_footer": "#27292b",
"dialog_label": "#dededc",
"dialog_rule": "#383c3f",
"dimension": "#c7b28b",
"dimension_driven": "#a59474",
"error": "#d7938f",
"field": "#1c1e20",
"form_accent": "#9d8faa",
"form_dim": "#c6c8c9",
"form_error": "#d7938f",
"form_error_fill": "#493334",
"form_fill": "#b9abc5",
"form_ink": "#dededc",
"form_primary": "#9eafbd",
"form_primary_fill": "#9eafbd",
"form_soft": "#343039",
"form_success": "#a5b9a5",
"form_success_fill": "#344139",
"form_tab_ink": "#b9abc5",
"glyph_bg": "#34373a",
"glyph_edge": "#d7938f",
"glyph_text": "#d7938f",
"grid": "#252729",
"grid_major": "#2e3235",
"hover": "#3b4852",
"hover_edge": "#b1c1ce",
"hover_edge_halo": "#181a1c",
"hover_profile": "#9eafbd30",
"hover_profile_edge": "#b1c1ce",
"icon": "#dededc",
"icons.agent": "#b8a7c3",
"icons.highlight": "#dededc",
"icons.marker_halo": "#1c1e20cc",
"interference": "#d7938f",
"joint_axis": "#98b2c8",
"joint_manipulator": "#9eafbd",
"logo": [
"#c9d4dc",
"#9eafbd",
"#647986"
],
"manipulator": "#9eafbd",
"mesh_accent": "#a29772",
"mesh_fill": "#beb48d",
"mesh_ink": "#beb48d",
"mesh_soft": "#34332b",
"nav_dim": "#b5b7b8",
"nav_field": "#1c1e20",
"nav_hover": "#43494f",
"nav_press": "#59636c",
"nav_tip": "#2b2d2f",
"nav_tip_ink": "#dededc",
"nav_tip_key_border": "#3b3e41",
"nav_viewport": "#27292b",
"on_accent": "#17191b",
"origin_plane": "#b9a78e2c",
"origin_plane_edge": "#b9a78e94",
"origin_plane_hover": "#9eafbd60",
"origin_point": "#b5b7b8",
"overlay": "#2b2d2f",
"panel": "#2b2d2f",
"panel_header": "#303235",
"preview_add": "#9eafbd",
"preview_cut": "#d7938f68",
"preview_new": "#c7b28b",
"preview_sketch": "#a5b9a5",
"profile_fill": "#9eafbd28",
"render_accent": "#a88e88",
"render_fill": "#c1a49e",
"render_ink": "#c1a49e",
"render_soft": "#38302f",
"rubber_band": "#9eafbd",
"section_cap": "#c7b28b",
"sel_edge": "#b1c1ce",
"sel_edge_rim": "#d0d9df",
"sel_face": "#3b4852",
"sel_profile": "#3b4852",
"sel_profile_edge": "#b1c1ce",
"sel_vertex": "#9eafbd",
"selected_bg": "#3b4852",
"selected_ink": "#dededc",
"sheet_fill": "#9eb9bd",
"sheet_ink": "#9eb9bd",
"sheet_soft": "#29363a",
"sketch": "#9eafbd",
"sketch_accent": "#b2b995",
"sketch_centerline": "#b5b7b8",
"sketch_conflict": "#d7938f",
"sketch_construction": "#b5b7b8",
"sketch_fixed": "#dededc",
"sketch_halo": "#181a1c",
"sketch_ink": "#b2b995",
"sketch_locked": "#a5b9a5",
"sketch_lost": "#d7938f",
"sketch_point": "#dededc",
"sketch_point_free": "#9eafbd",
"sketch_projected": "#b8a7c3",
"sketch_soft": "#32352c",
"surface_accent": "#a6947d",
"surface_fill": "#c0ae96",
"surface_ink": "#c0ae96",
"surface_soft": "#35322e",
"tab_on_fill_dark": "#17191b",
"text": "#dededc",
"text_dim": "#c6c8c9",
"timeline": "#2b2d2f",
"timeline_item": "#303235",
"timeline_item_error": "#493334",
"timeline_marker": "#b5b7b8",
"toolbar": "#2b2d2f",
"viewport_bottom": "#1b1d1f",
"viewport_top": "#222426",
"warning": "#c7b28b"
},
"radii": {
"radius_sm": 3,
"radius_md": 6,
"radius_lg": 8,
"radius_xl": 10,
"control_radius": 4,
"sketch_mode_radius": 6
},
"icon_palette": {
"ink": "#1b1d1f",
"line": "#d7d9d8",
"passive-1": "#b9bdc0",
"passive-2": "#8d9398",
"passive-3": "#646c72",
"guide": "#a2a8ac",
"solid": [
"#c9d4dc",
"#b2c1cc",
"#9eafbd"
],
"surface": [
"#d6cab8",
"#c0ae96",
"#a6947d"
],
"mesh": [
"#d4ccad",
"#beb48d",
"#a29772"
],
"form": [
"#d0c5d9",
"#b9abc5",
"#9d8faa"
],
"sketch": [
"#c9cfb3",
"#b2b995",
"#979f78"
],
"constraint": [
"#e1b7b2",
"#d7938f",
"#b77875"
],
"construction": [
"#d2c6b4",
"#b9a78e",
"#9f8c73"
]
},
"options": [
{
"id": "opacity",
"type": "range",
"default": 80,
"min": 60,
"max": 100
},
{
"id": "blur",
"type": "range",
"default": 60,
"max": 60
},
{
"id": "tab_colours",
"type": "bool",
"default": false
},
{
"id": "tab_fade_ms",
"type": "range",
"default": 240
},
{
"id": "accent",
"type": "choice",
"default": "slate",
"label": {
"en": "Accent",
"de": "Akzent",
"ja": "アクセント",
"ko": "강조색",
"zh": "强调色"
},
"help": {
"en": "Selection, highlights and Model controls; workspace and status colours keep their meaning.",
"de": "Auswahl, Hervorhebungen und Modell-Steuerung; Arbeitsbereichs- und Statusfarben behalten ihre Bedeutung.",
"ja": "選択・強調・モデルの操作色。ワークスペースや状態の色の意味は変えません。",
"ko": "선택, 강조 및 모델 조작의 색상입니다. 작업 공간과 상태 색상의 의미는 유지됩니다.",
"zh": "更改选择、高亮与模型控件的色调;工作空间和状态色保持原有含义。"
},
"values": [
{
"id": "slate",
"label": {
"en": "Slate blue",
"de": "Schieferblau",
"ja": "石板の青",
"ko": "슬레이트 블루",
"zh": "岩蓝"
}
},
{
"id": "silver",
"label": {
"en": "Silver",
"de": "Silber",
"ja": "シルバー",
"ko": "은색",
"zh": "银灰"
}
},
{
"id": "sage",
"label": {
"en": "Sage",
"de": "Salbei",
"ja": "セージ",
"ko": "세이지",
"zh": "鼠尾草绿"
}
}
]
},
{
"id": "contrast",
"type": "choice",
"default": "soft",
"label": {
"en": "Contrast",
"de": "Kontrast",
"ja": "コントラスト",
"ko": "대비",
"zh": "对比度"
},
"help": {
"en": "Soft stone layers or stronger separation between panels and fields.",
"de": "Weiche Steinschichten oder stärkere Trennung von Flächen und Feldern.",
"ja": "柔らかな石の階調か、パネルと入力欄を明確に分ける階調を選びます。",
"ko": "부드러운 석재 톤 또는 패널과 입력란을 더 뚜렷하게 구분합니다.",
"zh": "选择柔和的石色层次,或增强面板与输入框的区分。"
},
"values": [
{
"id": "soft",
"label": {
"en": "Soft",
"de": "Weich",
"ja": "ソフト",
"ko": "부드럽게",
"zh": "柔和"
}
},
{
"id": "defined",
"label": {
"en": "Defined",
"de": "Deutlich",
"ja": "くっきり",
"ko": "뚜렷하게",
"zh": "分明"
}
}
]
},
{
"id": "brightness",
"type": "range",
"effect": "surface_brightness",
"default": 100,
"min": 60,
"max": 120,
"step": 1,
"unit": "%",
"label": {
"en": "Brightness",
"de": "Helligkeit",
"ja": "明るさ",
"ko": "밝기",
"zh": "亮度"
},
"help": {
"en": "Adjust stone surfaces, viewport backgrounds and grid brightness. 100% keeps the authored tones; text and semantic colours stay unchanged.",
"de": "Helligkeit von Steinflächen, Ansichtsflächen und Raster. 100% erhält die ursprünglichen Töne; Text und Bedeutungsfarben bleiben unverändert.",
"ja": "石調の面・ビューポート背景・グリッドの明るさを調整します。100%は元の階調です。文字と意味のある色は変えません。",
"ko": "석재 표면·뷰포트 배경·격자의 밝기를 조절합니다. 100%는 원래 톤을 유지하며 글자와 의미 색상은 바꾸지 않습니다.",
"zh": "调整石色表面、视口背景和网格的亮度。100%保留原有色调;文字和语义颜色保持不变。"
}
},
{
"id": "foreground_colours",
"type": "choice",
"default": "vibrant",
"label": {
"en": "Foreground colours",
"de": "Vordergrundfarben",
"ja": "前景の色",
"ko": "전경 색상",
"zh": "前景颜色"
},
"help": {
"en": "Vibrant strengthens axes, icons and guides. Muted keeps the original quiet palette. Accent controls selection.",
"de": "Leuchtend verstärkt Achsen, Symbole und Hilfen. Gedämpft behält die ruhige ursprüngliche Palette. Akzent steuert die Auswahl.",
"ja": "鮮やかは軸・アイコン・作図ガイドを強調します。落ち着いた配色は元の控えめな色を保ちます。選択の色はアクセントで設定します。",
"ko": "선명함은 축·아이콘·가이드를 강조합니다. 차분함은 기존의 은은한 팔레트를 유지합니다. 선택 색상은 강조색 설정을 따릅니다.",
"zh": "鲜艳增强轴、图标和辅助线的色彩。柔和保留原有的沉静配色。选择颜色仍由强调色控制。"
},
"values": [
{
"id": "muted",
"label": {
"en": "Muted",
"de": "Gedämpft",
"ja": "落ち着いた",
"ko": "차분함",
"zh": "柔和"
}
},
{
"id": "vibrant",
"label": {
"en": "Vibrant",
"de": "Leuchtend",
"ja": "鮮やか",
"ko": "선명함",
"zh": "鲜艳"
}
}
]
}
],
"overlays": [
{
"when": {
"opt": "contrast",
"eq": "defined"
},
"colors": {
"app_bar": "#151719",
"panel": "#25282a",
"dialog_bg": "#25282a",
"dialog_body": "#25282a",
"dialog_footer": "#202325",
"panel_header": "#373b3e",
"field": "#171a1c",
"control_fill": "#383d41",
"border": "#4e555b",
"dialog_border": "#4e555b",
"dialog_rule": "#444b51",
"control_border": "#626c73",
"text": "#eeeeec",
"text_dim": "#ced1d2",
"form_ink": "#eeeeec",
"form_dim": "#ced1d2",
"dialog_label": "#eeeeec",
"toolbar": "#25282a",
"timeline": "#25282a"
}
},
{
"when": {
"opt": "accent",
"eq": "silver"
},
"colors": {
"accent": "#b7bdc2",
"manipulator": "#b7bdc2",
"joint_manipulator": "#b7bdc2",
"preview_add": "#b7bdc2",
"rubber_band": "#b7bdc2",
"box_window": "#b7bdc2",
"form_primary": "#b7bdc2",
"form_primary_fill": "#b7bdc2",
"sketch": "#b7bdc2",
"sketch_point_free": "#b7bdc2",
"sel_vertex": "#b7bdc2",
"accent_soft": "#454b50",
"hover": "#454b50",
"selected_bg": "#454b50",
"sel_face": "#454b50",
"sel_profile": "#454b50",
"body_selected": "#454b50",
"sel_edge": "#d5dadf",
"sel_profile_edge": "#d5dadf",
"hover_edge": "#d5dadf",
"hover_profile_edge": "#d5dadf",
"sel_edge_rim": "#dde0e3",
"hover_profile": "#b7bdc230",
"origin_plane_hover": "#b7bdc260",
"profile_fill": "#b7bdc228",
"cube_hover": "#454b50",
"chip": "#b7bdc2",
"logo": [
"#dde0e3",
"#c9cfd4",
"#b7bdc2"
]
},
"icon_palette": {
"solid": [
"#dde0e3",
"#c9cfd4",
"#b7bdc2"
]
}
},
{
"when": {
"opt": "accent",
"eq": "sage"
},
"colors": {
"accent": "#aabcae",
"manipulator": "#aabcae",
"joint_manipulator": "#aabcae",
"preview_add": "#aabcae",
"rubber_band": "#aabcae",
"box_window": "#aabcae",
"form_primary": "#aabcae",
"form_primary_fill": "#aabcae",
"sketch": "#aabcae",
"sketch_point_free": "#aabcae",
"sel_vertex": "#aabcae",
"accent_soft": "#414d45",
"hover": "#414d45",
"selected_bg": "#414d45",
"sel_face": "#414d45",
"sel_profile": "#414d45",
"body_selected": "#414d45",
"sel_edge": "#c2d2c5",
"sel_profile_edge": "#c2d2c5",
"hover_edge": "#c2d2c5",
"hover_profile_edge": "#c2d2c5",
"sel_edge_rim": "#d1ddd4",
"hover_profile": "#aabcae30",
"origin_plane_hover": "#aabcae60",
"profile_fill": "#aabcae28",
"cube_hover": "#414d45",
"chip": "#aabcae",
"logo": [
"#d1ddd4",
"#becec2",
"#aabcae"
]
},
"icon_palette": {
"solid": [
"#d1ddd4",
"#becec2",
"#aabcae"
]
}
},
{
"when": {
"opt": "foreground_colours",
"eq": "vibrant"
},
"colors": {
"axis_x": "#ff6d79",
"axis_y": "#4fd681",
"axis_z": "#4da7ff",
"agent_busy": "#4da7ff",
"agent_ok": "#4fd681",
"warning": "#ffd166",
"error": "#ff6d79",
"form_error": "#ff6d79",
"form_success": "#4fd681",
"glyph_edge": "#ff6d79",
"glyph_text": "#ff6d79",
"interference": "#ff6d79",
"sketch_conflict": "#ff6d79",
"sketch_lost": "#ff6d79",
"sketch_locked": "#4fd681",
"sketch_projected": "#c78bff",
"dimension": "#ffd166",
"dimension_driven": "#ffb166",
"construction_geom": "#ffb166",
"sketch_centerline": "#b5b7b8",
"sketch_construction": "#b5b7b8",
"joint_axis": "#42d9dd",
"bend_down": "#c78bff",
"bend_up": "#4fd681",
"preview_cut": "#ff6d7968",
"preview_new": "#ffd166",
"preview_sketch": "#4fd681",
"section_cap": "#ffd166",
"sketch_accent": "#4fd681",
"sketch_ink": "#4fd681",
"surface_accent": "#ffb166",
"surface_fill": "#ffb166",
"surface_ink": "#ffb166",
"mesh_accent": "#ffd166",
"mesh_fill": "#ffd166",
"mesh_ink": "#ffd166",
"form_accent": "#c78bff",
"form_fill": "#c78bff",
"form_tab_ink": "#c78bff",
"sheet_fill": "#63cbd5",
"sheet_ink": "#63cbd5",
"render_accent": "#ff6d79",
"render_fill": "#ff6d79",
"render_ink": "#ff6d79",
"icons.agent": "#c78bff",
"continuity": [
"#4fd681",
"#4da7ff",
"#ffd166",
"#c78bff"
],
"origin_plane_edge": "#ffb16694",
"sheet_soft": "#283a3e"
},
"icon_palette": {
"ink": "#1b1d1f",
"line": "#d7d9d8",
"passive-1": "#d3d8dc",
"passive-2": "#b2bbc2",
"passive-3": "#929fa9",
"guide": "#b4c8d6",
"solid": [
"#82c1ff",
"#62b2ff",
"#4da7ff"
],
"surface": [
"#ffc894",
"#ffba78",
"#ffb166"
],
"mesh": [
"#ffdf94",
"#ffd778",
"#ffd166"
],
"form": [
"#d8aeff",
"#ce99ff",
"#c78bff"
],
"sketch": [
"#84e2a7",
"#64db90",
"#4fd681"
],
"constraint": [
"#ff99a1",
"#ff7f89",
"#ff6d79"
],
"construction": [
"#ffc894",
"#ffba78",
"#ffb166"
]
}
}
],
"tabs": {
"MODEL": {
"viewport_top": "#24292d",
"viewport_bottom": "#1b2023",
"panel": "#303438"
},
"SURFACE": {
"viewport_top": "#292622",
"viewport_bottom": "#211e1b",
"panel": "#36332f"
},
"MESH": {
"viewport_top": "#282820",
"viewport_bottom": "#202019",
"panel": "#35352e"
},
"FORM": {
"viewport_top": "#26232b",
"viewport_bottom": "#1e1b22",
"panel": "#343137"
},
"SHEET METAL": {
"viewport_top": "#22282a",
"viewport_bottom": "#1b2022",
"panel": "#303638"
},
"RENDER": {
"viewport_top": "#2b2423",
"viewport_bottom": "#221c1b",
"panel": "#373130"
},
"SKETCH": {
"viewport_top": "#252921",
"viewport_bottom": "#1d211a",
"panel": "#33362f"
}
},
"borders": {
"primary_inset": {
"width": 0.5,
"gap": 1,
"color": "#dce2e70e"
}
},
"requires": 2
}
-8
View File
@@ -1,8 +0,0 @@
| assets/fonts/noto-sans-arabic/NotoSansArabic-Regular.ttf | The Noto Project Authors (Noto Sans Arabic 2.013, hinted) | https://github.com/notofonts/notofonts.github.io (fonts/NotoSansArabic/hinted/ttf), unmodified; licence text in assets/fonts/noto-sans-arabic/OFL.txt; the web app's font for a drawing's Arabic text | SIL OFL 1.1 |
| assets/fonts/noto-sans-arabic/OFL.txt | The Noto Project Authors | Unmodified accompanying font licence (https://github.com/notofonts/arabic) | SIL OFL 1.1 |
| assets/fonts/noto-sans-kr/NotoSansKR-Regular.otf | Adobe (Noto Sans KR 2.004, Noto Sans CJK subset) | https://github.com/notofonts/noto-cjk (Sans/SubsetOTF/KR), unmodified; licence text in assets/fonts/noto-sans-kr/OFL.txt; the web app's font for a drawing's Korean text | SIL OFL 1.1 |
| assets/fonts/noto-sans-kr/OFL.txt | Adobe | Unmodified accompanying font licence (https://github.com/notofonts/noto-cjk, Sans/LICENSE) | SIL OFL 1.1 |
| assets/fonts/noto-sans-sc/NotoSansSC-Regular.otf | Adobe (Noto Sans SC 2.004, Noto Sans CJK subset) | https://github.com/notofonts/noto-cjk (Sans/SubsetOTF/SC), unmodified; licence text in assets/fonts/noto-sans-sc/OFL.txt; the web app's font for a drawing's simplified Chinese text | SIL OFL 1.1 |
| assets/fonts/noto-sans-sc/OFL.txt | Adobe | Unmodified accompanying font licence (https://github.com/notofonts/noto-cjk, Sans/LICENSE) | SIL OFL 1.1 |
| assets/fonts/noto-sans-tc/NotoSansTC-Regular.otf | Adobe (Noto Sans TC 2.004, Noto Sans CJK subset) | https://github.com/notofonts/noto-cjk (Sans/SubsetOTF/TC), unmodified; licence text in assets/fonts/noto-sans-tc/OFL.txt; the web app's font for a drawing's traditional Chinese text | SIL OFL 1.1 |
| assets/fonts/noto-sans-tc/OFL.txt | Adobe | Unmodified accompanying font licence (https://github.com/notofonts/noto-cjk, Sans/LICENSE) | SIL OFL 1.1 |
@@ -1,22 +0,0 @@
| assets/icons/ui/design/svg/model/drawing_base_view.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_projected_view.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_section_view.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_detail_view.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_align_views.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_image.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_dimension.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_hole_callout.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_leader.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_datum.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_centre_mark.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_align_annotations.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_space_annotations.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_parts_list.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_balloon.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_edit_table.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_revision_table.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_update.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_check.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_revision.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_print.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
| assets/icons/ui/design/svg/model/drawing_resources.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 |
+9 -96
View File
@@ -29,18 +29,6 @@ pub fn mat_mul(a: &Mat, b: &Mat) -> Mat {
r
}
/// How much `m` scales a volume: the size of the determinant of its linear part (1 for a rigid
/// placement).
pub fn mat_volume_scale(m: &Mat) -> f64 {
let c = |i: usize| m.get(i).map_or(Vec3::ZERO, |col| Vec3::new(col[0], col[1], col[2]));
c(0).dot(c(1).cross(c(2))).abs()
}
/// Why an assembly too large to walk whole is refused.
pub(crate) fn too_many_placements() -> crate::DocError {
crate::DocError::Invalid(format!("the assembly is deeper than {MAX_PLACEMENT_DEPTH} levels or has more than {MAX_PLACEMENTS} placements"))
}
pub fn apply_point(m: &Mat, p: Vec3) -> Vec3 {
Vec3::new(
m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + m[3][0],
@@ -57,11 +45,6 @@ pub fn apply_vector(m: &Mat, v: Vec3) -> Vec3 {
)
}
/// `p` carried by `m`: `None` when `m` flattens it.
pub fn map_plane(m: &Mat, p: &Plane) -> Option<Plane> {
Plane::new(apply_point(m, p.origin), apply_vector(m, p.x), apply_vector(m, p.y))
}
pub fn is_identity(m: &Mat) -> bool {
m.iter().flatten().zip(IDENTITY.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-12)
}
@@ -153,10 +136,6 @@ pub struct Occurrence {
/// Grounded occurrences don't move.
#[serde(default)]
pub grounded: bool,
/// How this occurrence counts in the bill of materials, in place of its component's type
/// (`None`: as its component).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bom_type: Option<BomType>,
}
fn identity() -> Mat {
@@ -165,8 +144,7 @@ fn identity() -> Mat {
/// How a component counts in the bill of materials of an assembly it is placed in. It is a part
/// property (the `bom_type` key of the component's [`Metadata`](crate::Metadata)); a component
/// without one is [`BomType::Normal`]. One occurrence can count as another type
/// ([`Occurrence::bom_type`]).
/// without one is [`BomType::Normal`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BomType {
@@ -210,16 +188,21 @@ impl BomType {
pub const MAX_PLACEMENT_DEPTH: usize = 64;
pub const MAX_PLACEMENTS: usize = 100_000;
fn map_plane(m: &Mat, p: &Plane) -> Plane {
let (o, x, y) = (apply_point(m, p.origin), apply_vector(m, p.x), apply_vector(m, p.y));
Plane::new(o, x, y).unwrap_or(*p)
}
fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
match p {
PlaneRef::Custom { plane } => *plane = map_plane(m, plane).unwrap_or(*plane),
PlaneRef::Custom { plane } => *plane = map_plane(m, plane),
PlaneRef::Placed { origin, x_dir, y_dir } => {
map_point_expr(m, origin);
*x_dir = apply_vector(m, *x_dir);
*y_dir = apply_vector(m, *y_dir);
}
PlaneRef::Face { plane, at, .. } => {
*plane = map_plane(m, plane).unwrap_or(*plane);
*plane = map_plane(m, plane);
*at = apply_point(m, *at);
}
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => map_plane_ref(m, base),
@@ -341,22 +324,6 @@ impl Document {
self.occurrences.iter().find(|o| o.component == component)
}
/// The bill of materials type each occurrence counts as, in the order of `occurrences`: its
/// own ([`Occurrence::bom_type`]), else its component's. Components are looked up once.
pub fn occurrence_bom_types(&self) -> Vec<BomType> {
let types: std::collections::HashMap<u64, BomType> =
self.components.iter().map(|c| (c.id, c.metadata.bom_type())).filter(|(_, t)| *t != BomType::Normal).collect();
self.occurrences.iter().map(|o| o.bom_type.or_else(|| types.get(&o.component).copied()).unwrap_or_default()).collect()
}
/// The occurrences that count as reference ([`Document::occurrence_bom_types`]). A body placed
/// through any of them is a reference part: the bill of materials counts nothing inside a
/// reference, and a drawing draws all of it as reference.
pub fn reference_occurrences(&self) -> std::collections::HashSet<u64> {
let types = self.occurrences.iter().zip(self.occurrence_bom_types());
types.filter(|(_, bom_type)| *bom_type == BomType::Reference).map(|(o, _)| o.id).collect()
}
/// World transform of a component's frame (through its first occurrence and its parents').
pub fn component_transform(&self, component: u64) -> Mat {
transform_through(component, |c| self.occurrence_of(c))
@@ -407,45 +374,6 @@ impl Document {
out
}
/// Whether [`Document::placements`] reaches every placement. Its walk stops at
/// [`MAX_PLACEMENTS`], and a branch past [`MAX_PLACEMENT_DEPTH`] levels, so a placement it
/// holds may have occurrences it never walked; the walk is whole when none does.
pub fn placements_complete(&self) -> bool {
let mut children = std::collections::HashMap::<u64, usize>::new();
for o in &self.occurrences {
*children.entry(o.parent).or_default() += 1;
}
let walk = self.placements();
let reached = walk.iter().fold(0usize, |n, (c, _, _)| n.saturating_add(children.get(c).copied().unwrap_or(0)));
walk.len() == reached.saturating_add(1)
}
/// The placement `path` of `component` at `world`, and every placement below it: (component,
/// occurrence path ids, world transform), the start first. Unlike [`Self::placements`], which
/// stops a branch where the display does, a larger assembly is refused, never walked in part:
/// for totals that must cover all of it (physical properties).
pub fn placements_below(&self, component: u64, path: &[u64], world: Mat) -> crate::Result<Vec<(u64, Vec<u64>, Mat)>> {
let mut children: std::collections::HashMap<u64, Vec<&Occurrence>> = std::collections::HashMap::new();
for o in &self.occurrences {
children.entry(o.parent).or_default().push(o);
}
let mut out = vec![(component, path.to_vec(), world)];
let mut stack = vec![(component, path.to_vec(), world)];
while let Some((parent, path, world)) = stack.pop() {
for o in children.get(&parent).into_iter().flatten() {
if path.len() >= MAX_PLACEMENT_DEPTH || out.len() > MAX_PLACEMENTS {
return Err(too_many_placements());
}
let mut child = path.clone();
child.push(o.id);
let w = mat_mul(&world, &o.transform);
out.push((o.component, child.clone(), w));
stack.push((o.component, child, w));
}
}
Ok(out)
}
/// Every body as the world shows it: its world name ("Body1", or "Body1 (Occ:2)" for a
/// further instance), its component, the occurrence path, and the transform from the frame
/// its geometry is in to the world.
@@ -512,21 +440,6 @@ impl Document {
false
}
/// The virtual components that hold something, by id: a feature (a sketch, a body's, a moved
/// body's) or a placed component. A virtual component holds nothing ([`crate::metadata::VIRTUAL`]):
/// the engine refuses any change that puts something in one.
pub fn virtual_holding(&self) -> Vec<u64> {
let virtuals: std::collections::HashSet<u64> = self.components.iter().filter(|c| c.metadata.is_virtual()).map(|c| c.id).collect();
if virtuals.is_empty() {
return Vec::new();
}
let mut held: Vec<u64> =
self.features.iter().map(|f| f.component).chain(self.occurrences.iter().map(|o| o.parent)).filter(|id| virtuals.contains(id)).collect();
held.sort_unstable();
held.dedup();
held
}
/// Is `c` (or one of its ancestors) marked out of the History bar (#10393)? The root (0)
/// never is: its own features always show.
pub fn component_history_hidden(&self, mut c: u64) -> bool {
@@ -590,7 +503,7 @@ impl Document {
!self.occurrences.iter().any(|o| o.name == name)
};
let k = std::iter::once(next).chain(1..).find(free).unwrap_or(next);
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false, bom_type: None });
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false });
Ok(id)
}
}
-964
View File
@@ -1,964 +0,0 @@
//! The bill of materials of an assembly (the design, or one component's definition): the rows a
//! bill of materials view lists, typed. `assembly.bom`'s JSON and CSV and the Bill of Materials
//! window present these rows; parts lists, item numbers and balloons are to read them too.
//!
//! [`list`] walks the placements below the assembly once. One rule (`classify`) decides for each
//! placement whether it is counted in a row, how what it holds is walked and what its row stands
//! for when weighed, from the bill of materials type it counts as (its occurrence's, else its
//! component's), the view and the component's shape. One budget of [`MAX_PLACEMENTS`] placements
//! and [`MAX_PLACEMENT_DEPTH`] levels covers the whole list, folded parts' contents included: a
//! larger assembly is refused, never listed in part.
//!
//! A row is named by its `RowKey`: the rows it is listed under and its lowest member (a
//! component and the type it counts as there), however its part number or contents read. Its
//! mass is what one of its parts weighs as the row stands for it (`Holds`), so the rows of any
//! list add up to the assembly once. The keys, what rows stand for and their members stay inside
//! this crate: a balloon reads its row's item ([`Bom::item_of`]), and item numbers are saved by key ([`numbers`]). Placements are rigid, so
//! each definition is weighed once however often it is placed, and kept in the [`Tree`] for every list made from it. The
//! assembly's own mass is weighed the same way, never added up from rows: a row whose definitions
//! weigh differently has none.
//!
//! Materials are bodies' (`material.assign`): a row names the materials of the bodies it stands for,
//! and a row that stands for an assembly names one only when everything in it shares it. A static
//! material typed in for a component ([`metadata::MATERIAL`](crate::metadata::MATERIAL)) is what
//! its rows say instead, in every view and at every level ([`Row::statics`]), and what it stands
//! for in the one material an assembly around it shares: the bill of materials says one thing
//! about the part everywhere. It only names: mass still comes from the bodies.
//!
//! A row's [`Quantity`] adds up what each of its placements means ([`quantity`]): each definition's
//! quantity is evaluated once for every list made from the [`Tree`].
use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
use crate::{BomType, Component, DocError, Document, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, ModelBody, ModelState, Result};
mod numbers;
mod quantity;
pub use numbers::{ItemNumber, ItemNumbers, MAX_ITEM, Numbering, Order, number, record};
pub use quantity::{Amount, Quantity, Uncounted, amount};
/// A bill of materials view, saved by the word commands use for it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum View {
/// Parts only: every part at any depth, counted through its subassemblies.
#[default]
#[serde(rename = "flat")]
PartsOnly,
/// Structured, first level: the assembly's direct contents, a subassembly as one row.
#[serde(rename = "top")]
FirstLevel,
/// Structured, all levels: the assembly's direct contents, each subassembly a row with its own
/// contents listed under it, to any depth (1, 1.1, 1.1.1).
#[serde(rename = "all")]
AllLevels,
}
impl View {
/// Every view, in the order commands and documentation list them.
pub const ALL: [View; 3] = [View::PartsOnly, View::FirstLevel, View::AllLevels];
/// The word commands use (`assembly.bom`'s `levels`).
pub fn as_str(self) -> &'static str {
match self {
View::PartsOnly => "flat",
View::FirstLevel => "top",
View::AllLevels => "all",
}
}
/// The view a word names, if it names one.
pub fn parse(word: &str) -> Option<View> {
View::ALL.into_iter().find(|v| v.as_str() == word)
}
}
/// What one row gathers.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Group {
/// The definitions that share a part number; a definition without one is a row of its own.
#[default]
PartNumber,
/// One definition.
Definition,
}
impl Group {
/// Every grouping, in the order commands and documentation list them.
pub const ALL: [Group; 2] = [Group::PartNumber, Group::Definition];
/// The word commands use (`assembly.bom`'s `group_by`).
pub fn as_str(self) -> &'static str {
match self {
Group::PartNumber => "part_number",
Group::Definition => "definition",
}
}
/// The grouping a word names, if it names one.
pub fn parse(word: &str) -> Option<Group> {
Group::ALL.into_iter().find(|g| g.as_str() == word)
}
}
/// What a bill of materials lists. The default is the design's parts only, by part number.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Request {
/// The assembly listed: a component, or 0 for the design.
pub source: u64,
pub view: View,
pub group: Group,
/// A normal part with a part number is one row, and what it holds is listed under it as its
/// children (as parts only lists them) instead of as rows of their own.
pub fold: bool,
/// Weigh each row and the assembly: every part is measured, so it is asked for.
pub mass: bool,
/// In all levels, what a purchased part holds is listed under it too (it is still one row,
/// weighed whole).
pub purchased_contents: bool,
}
/// A component as one placement counts: its id and the bill of materials type it counts as there.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Member {
pub component: u64,
pub bom_type: BomType,
}
/// A row's identity: the key members of the rows it is listed under (outermost first; none at the
/// top), and its own lowest member. It doesn't change with the row's part number or contents, so
/// item numbers, overrides and balloons can follow the row through those edits.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub(crate) struct RowKey {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) parents: Vec<Member>,
pub(crate) member: Member,
}
/// What one of a row's parts stands for when it is weighed, so that the rows of any list add up
/// to the assembly once.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum Holds {
/// Its own bodies and everything placed inside it.
All,
/// Its own bodies alone: what it holds is listed beside it.
Own,
/// Everything but its purchased parts, which are listed beside it (an inseparable part in
/// parts only).
AllButPurchased,
}
/// What one of a row's parts weighs, or the assembly.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum Mass {
/// Not asked for ([`Request::mass`]).
#[default]
NotAsked,
Grams(f64),
Unknown(Unknown),
}
/// Why a mass isn't known.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unknown {
/// Nothing in it has a body.
NothingToWeigh,
/// A body in it can't be measured, or measures inside out (a negative volume).
Unmeasurable,
/// The definitions the row gathers (one part number) weigh differently.
Differs,
}
/// A value of a row typed in by hand for its component, in place of the one worked out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Static {
/// [`Row::materials`], from [`metadata::MATERIAL`](crate::metadata::MATERIAL).
Material,
}
impl Static {
/// The part property it is kept under, and its name in `assembly.bom`'s rows.
pub fn key(self) -> &'static str {
match self {
Static::Material => crate::metadata::MATERIAL,
}
}
}
/// One row of a bill of materials view.
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
pub(crate) key: RowKey,
/// Its item number among the rows it is listed with: the one saved for it, else its position
/// from 1 (the view not numbered yet) or the next number after the saved ones ([`numbers`]).
pub item: u32,
/// Its item number isn't saved yet.
pub provisional: bool,
/// Its item number is locked: a renumber keeps it.
pub locked: bool,
/// Another row listed with it has the same item number.
pub duplicate: bool,
pub bom_type: BomType,
/// What one of its parts stands for when weighed.
pub(crate) stands_for: Holds,
/// The definitions it gathers, by id (ascending), and their names, each once.
pub components: Vec<u64>,
pub names: Vec<String>,
/// Its part properties: its first definition's part number (the one it gathers by), and the
/// first description and revision its definitions give.
pub part_number: String,
pub description: String,
pub revision: String,
/// The definitions' free properties; where several give one name, the first's.
pub properties: BTreeMap<String, String>,
/// The physical materials of what one of its parts stands for, each once, in body order: a
/// part's own bodies'. A row that stands for an assembly (something placed inside it is weighed
/// with it) shows the one material all of that shares, else none. A static material replaces
/// them.
pub materials: Vec<String>,
/// Its values typed in by hand ([`Static`]). Where its definitions give different ones, the
/// first's that gives one wins, as with its description.
pub statics: Vec<Static>,
/// How many are listed: in all, and in one of the row it is listed under (the assembly at the
/// top).
pub count: u64,
pub each: u64,
/// What its placements add up to, when its definitions have quantities.
pub quantity: Quantity,
/// The placements it counts, by occurrence path from the assembly listed, in path order;
/// empty unless the list was made with them (`list_with`).
pub(crate) members: Vec<Vec<u64>>,
pub mass: Mass,
/// What one of it holds, listed under it: a folded part's contents as parts only lists them,
/// or in all levels a subassembly's own rows.
pub children: Vec<Row>,
}
/// The assembly as a whole.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Total {
/// Every placement listed, children included.
pub occurrences: u64,
/// Its own bodies and all it holds but references, weighed from the design (with the rows'
/// measurements), not added up from rows.
pub mass: Mass,
}
/// The rows of one bill of materials view.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Bom {
/// The assembly listed: a component, or 0 for the design.
pub source: u64,
/// By part number, rows without one after them by name, one part's rows by type (`list_order`);
/// each row's children likewise. In a numbered view, by item number.
pub rows: Vec<Row>,
pub total: Total,
/// Its item numbers are saved: it has been numbered ([`ItemNumbers`]).
pub numbered: bool,
/// Each member path, and where its row is (its position among the rows, then among each one's
/// children): `row_of` reads it. Empty unless the list was made with its members.
index: HashMap<Vec<u64>, Vec<usize>>,
}
/// Why a placement is counted in no row of a list.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unlisted {
/// No placement below the assembly listed has that path.
NotFound,
/// It counts as a reference, or is inside one: no bill of materials counts it.
Reference,
/// It has no row of its own and isn't inside one: a phantom without bodies, or in parts only
/// a subassembly listed through its parts (the assembly listed, an empty path, is none either).
NoRow,
}
impl Bom {
/// Every row, each followed by the rows listed under it (1, 1.1, 1.2, 2…), with its item
/// numbers from the top (`[1, 2]` reads 1.2).
pub fn rows_deep(&self) -> Vec<(Vec<u32>, &Row)> {
let mut out = Vec::new();
let mut stack: Vec<(Vec<u32>, &Row)> = self.rows.iter().rev().map(|r| (vec![r.item], r)).collect();
while let Some((item, row)) = stack.pop() {
for child in row.children.iter().rev() {
let mut under = item.clone();
under.push(child.item);
stack.push((under, child));
}
out.push((item, row));
}
out
}
/// The row a placement is counted in, by its occurrence path from the assembly listed: the row
/// that lists it, else the row of the part it is inside (a nut inside a purchased bolt is
/// counted in the bolt's). `tree` is the one the list was made from, with its members
/// (`list_with`).
pub(crate) fn row_of(&self, tree: &Tree, path: &[u64]) -> std::result::Result<(Vec<u32>, &Row), Unlisted> {
if path.len() > MAX_PLACEMENT_DEPTH {
return Err(Unlisted::NotFound);
}
let mut component = self.source;
for id in path {
let placed = tree.contents(component).iter().find(|p| p.occurrence == *id).ok_or(Unlisted::NotFound)?;
if placed.member.bom_type == BomType::Reference {
return Err(Unlisted::Reference);
}
component = placed.member.component;
}
// Each placement is in one row or none, so the longest member path it starts with is its row's.
let at = (1..=path.len()).rev().find_map(|n| path.get(..n).and_then(|start| self.index.get(start)));
at.and_then(|at| self.row_at(at)).ok_or(Unlisted::NoRow)
}
/// The row at `at` (its position among the rows, then among each one's children), with its item
/// numbers from the top.
fn row_at(&self, at: &[usize]) -> Option<(Vec<u32>, &Row)> {
let (first, rest) = at.split_first()?;
let mut row = self.rows.get(*first)?;
let mut item = vec![row.item];
for &i in rest {
row = row.children.get(i)?;
item.push(row.item);
}
Some((item, row))
}
/// The item ("1.2") of the row a placement is counted in, by its occurrence path from the top of
/// the design: a balloon's, which is cut to start below the assembly listed. A path that doesn't
/// run through that assembly is [`Unlisted::NotFound`]; through one of its placements, whichever,
/// it reads the same row. `tree` is as for `row_of`.
pub fn item_of(&self, tree: &Tree, path: &[u64]) -> std::result::Result<String, Unlisted> {
let below = tree.below(self.source, path).ok_or(Unlisted::NotFound)?;
self.row_of(tree, below).map(|(item, _)| item_text(&item))
}
/// Where each member's row is, for `row_of`.
fn index_members(&mut self) {
let mut index = HashMap::new();
let mut stack: Vec<(Vec<usize>, &Row)> = self.rows.iter().enumerate().map(|(i, r)| (vec![i], r)).collect();
while let Some((at, row)) = stack.pop() {
for member in &row.members {
index.insert(member.clone(), at.clone());
}
for (i, child) in row.children.iter().enumerate() {
let mut under = at.clone();
under.push(i);
stack.push((under, child));
}
}
self.index = index;
}
}
/// Item numbers as one reads them: `[1, 2]` is "1.2".
pub fn item_text(item: &[u32]) -> String {
item.iter().map(u32::to_string).collect::<Vec<_>>().join(".")
}
/// The design's structure as bills of materials read it, looked up once, and what each
/// definition weighs, measured once for every list made from it. It reads one state of the
/// design: make another after a change.
pub struct Tree<'a> {
doc: &'a Document,
/// What each component places (by its id; 0 is the root), in occurrence order.
children: HashMap<u64, Vec<Placed>>,
components: HashMap<u64, &'a Component>,
/// Each component's own bodies, in body order; a component that owns none isn't here.
bodies: HashMap<u64, Vec<&'a ModelBody>>,
/// What one of each definition weighs, by what it stands for.
weighed: BTreeMap<(u64, Holds), Weighed>,
/// The materials of what one of each definition stands for, each once, in body order.
made_of: BTreeMap<(u64, Holds), Vec<String>>,
/// What one placement of each definition with a quantity counts: `None` when it doesn't
/// evaluate.
amounts: HashMap<u64, Option<Amount>>,
}
/// One occurrence: its id, and the component it places with the type it counts as there.
#[derive(Clone, Copy, Debug)]
struct Placed {
occurrence: u64,
member: Member,
}
/// What one of a component weighs: grams, and how many bodies that is (none: nothing to weigh).
/// `None` when a body in it can't be measured, or measures inside out.
type Weighed = Option<(f64, usize)>;
impl<'a> Tree<'a> {
/// The structure of `doc` with the bodies of `state`, its evaluated model.
pub fn new(doc: &'a Document, state: &'a ModelState) -> Tree<'a> {
let mut children: HashMap<u64, Vec<Placed>> = HashMap::new();
for (o, bom_type) in doc.occurrences.iter().zip(doc.occurrence_bom_types()) {
children.entry(o.parent).or_default().push(Placed { occurrence: o.id, member: Member { component: o.component, bom_type } });
}
let mut components: HashMap<u64, &Component> = HashMap::new();
for c in &doc.components {
components.entry(c.id).or_insert(c);
}
let mut bodies: HashMap<u64, Vec<&ModelBody>> = HashMap::new();
for (body, owner) in state.bodies.iter().zip(doc.model_body_components(state)) {
bodies.entry(owner).or_default().push(body);
}
let values = std::cell::OnceCell::new();
let amounts = components.values().filter_map(|c| Some((c.id, quantity::amount_in(doc, &values, c.metadata.bom_quantity()?).ok()))).collect();
Tree { doc, children, components, bodies, weighed: BTreeMap::new(), made_of: BTreeMap::new(), amounts }
}
/// What a component places.
fn contents(&self, id: u64) -> &[Placed] {
self.children.get(&id).map_or(&[], Vec::as_slice)
}
/// What of `path`, from the top of the design, runs below `source`'s first placement on it.
fn below<'p>(&self, source: u64, path: &'p [u64]) -> Option<&'p [u64]> {
if source == 0 {
return Some(path);
}
let mut component = 0;
for (i, id) in path.iter().enumerate() {
component = self.contents(component).iter().find(|p| p.occurrence == *id)?.member.component;
if component == source {
return path.get(i + 1..);
}
}
None
}
/// The components placed in `id` whose parts are part of what `id` stands for as `holds` says
/// (see `adds`), one for each placement, with the type each counts as there.
fn adding(&self, id: u64, holds: Holds) -> Vec<Member> {
self.contents(id).iter().filter(|p| adds(holds, p.member.bom_type)).map(|p| p.member).collect()
}
/// The static material typed in for a component, if any.
fn static_material(&self, id: u64) -> Option<&'a str> {
self.components.get(&id).and_then(|c| c.metadata.material())
}
fn own_bodies(&self, id: u64) -> &[&'a ModelBody] {
self.bodies.get(&id).map_or(&[], Vec::as_slice)
}
/// What a row of `member` gathers by: its part number when `group` asks for that and it has
/// one, else its definition. `None` for a damaged file's occurrence of a component that isn't
/// there, which has no row.
fn part(&self, member: Member, group: Group) -> Option<Part<'a>> {
let c: &'a Component = self.components.get(&member.component)?;
Some(match c.metadata.part_number() {
Some(pn) if group == Group::PartNumber => Part::Number(pn),
_ => Part::Definition(member.component),
})
}
/// The rows of one level: the placements `at` (positions in `entries`), gathered as `group`
/// says, listed under the rows `parents` names, `per` of them in all (1 for the assembly).
/// The rows under these are gathered as `under` says.
#[allow(clippy::too_many_arguments)]
fn level(&self, listing: &Listing, paths: &mut [Vec<u64>], at: &[usize], group: Group, under: Group, parents: &[Member], per: u64) -> Vec<Row> {
let mut grouped: BTreeMap<GroupKey<'a>, Vec<usize>> = BTreeMap::new();
for &i in at {
let Some(entry) = listing.entries.get(i) else { continue };
let Some(part) = self.part(entry.member, group) else { continue };
let kit = listing.kits.get(i).copied().unwrap_or_default();
grouped.entry(GroupKey { part, bom_type: entry.member.bom_type, kit }).or_default().push(i);
}
let mut rows: Vec<Row> = grouped.values().filter_map(|members| self.row(listing, paths, members, under, parents, per)).collect();
rows.sort_by(list_order);
for (item, row) in (1..).zip(&mut rows) {
row.item = item;
}
rows
}
/// Each entry's kit, as a number: what it holds as its row's children read it, to any depth.
/// Two entries have the same kit when what they hold is the same parts (by part number, else
/// definition), counted as the same types, each holding the same again all the way down. One
/// pass from the last entry back: an entry's children come after it, so theirs are known first.
fn kits(&self, entries: &[Entry], kids: &[Vec<usize>]) -> Vec<u32> {
let mut kit_ids: HashMap<Vec<u32>, u32> = HashMap::new();
let mut placed_ids: HashMap<(Option<Part<'a>>, BomType, u32), u32> = HashMap::new();
// Each entry as a child reads: its part, its type and its own kit.
let mut placed = vec![0u32; entries.len()];
let mut kits = vec![0u32; entries.len()];
for (i, entry) in entries.iter().enumerate().rev() {
let mut held: Vec<u32> = kids.get(i).into_iter().flatten().filter_map(|&k| placed.get(k).copied()).collect();
held.sort_unstable();
let next = kit_ids.len() as u32;
let kit = *kit_ids.entry(held).or_insert(next);
let next = placed_ids.len() as u32;
let key = (self.part(entry.member, Group::PartNumber), entry.member.bom_type, kit);
if let (Some(k), Some(p)) = (kits.get_mut(i), placed.get_mut(i)) {
*k = kit;
*p = *placed_ids.entry(key).or_insert(next);
}
}
kits
}
/// One row: the placements `members` (positions in `entries`) of one part, type and kit, with
/// the rows under it gathered as `under` says. Their `paths` (when the list keeps them) move into
/// the row: each placement is in one row.
#[allow(clippy::too_many_arguments)]
fn row(&self, listing: &Listing, paths: &mut [Vec<u64>], members: &[usize], under: Group, parents: &[Member], per: u64) -> Option<Row> {
let listed: Vec<&Entry> = members.iter().filter_map(|&i| listing.entries.get(i)).collect();
let first = listed.first()?;
let mut components: Vec<u64> = listed.iter().map(|e| e.member.component).collect();
components.sort_unstable();
components.dedup();
let member = Member { component: *components.first()?, bom_type: first.member.bom_type };
let count = listed.len() as u64;
let mut taken: Vec<Vec<u64>> = members.iter().filter_map(|&i| paths.get_mut(i).map(std::mem::take)).filter(|p| !p.is_empty()).collect();
taken.sort_unstable();
// What one of it holds is listed under it.
let inside: Vec<usize> = members.iter().flat_map(|&i| listing.kids.get(i).into_iter().flatten().copied()).collect();
let mut chain = parents.to_vec();
chain.push(member);
let mut row = Row {
key: RowKey { parents: parents.to_vec(), member },
item: 0,
provisional: true,
locked: false,
duplicate: false,
bom_type: member.bom_type,
stands_for: first.holds,
// Filled in by `name_materials`, which reads each definition once.
materials: Vec::new(),
statics: Vec::new(),
components,
names: Vec::new(),
part_number: String::new(),
description: String::new(),
revision: String::new(),
properties: BTreeMap::new(),
count,
// Rows with different kits are different rows, to any depth, so every part of the parent
// row holds as many.
each: count / per.max(1),
quantity: quantity::add_up(listed.iter().map(|e| self.amounts.get(&e.member.component))),
members: taken,
mass: Mass::NotAsked,
children: self.level(listing, paths, &inside, under, under, &chain, count),
};
for (i, c) in row.components.iter().filter_map(|id| self.components.get(id)).enumerate() {
let m = &c.metadata;
if i == 0 {
row.part_number = m.part_number().unwrap_or_default().to_string();
}
if !row.names.contains(&c.name) {
row.names.push(c.name.clone());
}
if row.description.is_empty() {
row.description = m.description().unwrap_or_default().to_string();
}
if row.revision.is_empty() {
row.revision = m.revision().unwrap_or_default().to_string();
}
for (k, v) in &c.properties {
row.properties.entry(k.clone()).or_insert_with(|| v.clone());
}
}
Some(row)
}
/// Each row's materials, and its children's (Cameron, 2026-10-23): a part lists its own bodies';
/// a row that stands for an assembly shows a material only when everything it stands for
/// shares it, so a frame of steel and aluminium parts reads blank and a bought bearing of steel
/// parts reads steel. A static material, the first its definitions give, replaces that.
fn name_materials(&mut self, rows: &mut [Row]) -> Result<()> {
for row in rows {
self.name_materials(&mut row.children)?;
let typed_in = row.components.iter().find_map(|&id| self.static_material(id));
if let Some(material) = typed_in {
row.materials = vec![material.to_string()];
row.statics.push(Static::Material);
continue;
}
let mut names: Vec<String> = Vec::new();
let mut assembly = false;
for &c in &row.components {
assembly |= row.stands_for != Holds::Own && !self.adding(c, row.stands_for).is_empty();
for name in self.made_of(c, row.stands_for, 0)? {
push_once(&mut names, name);
}
}
row.materials = if assembly && names.len() > 1 { Vec::new() } else { names };
}
Ok(())
}
/// The materials of what one of `id` stands for as `holds` says, each once, in body order: the
/// bodies `weigh` weighs. A static material stands for all of a part placed inside it, and for
/// a phantom's own bodies only (a phantom's row is its own bodies, so what it holds keeps its
/// own). Each definition is read once for each kind of row; `depth` bounds the walk below a
/// definition not read yet.
fn made_of(&mut self, id: u64, holds: Holds, depth: usize) -> Result<Vec<String>> {
if let Some(names) = self.made_of.get(&(id, holds)) {
return Ok(names.clone());
}
if depth > MAX_PLACEMENT_DEPTH {
return Err(too_big());
}
let mut names: Vec<String> = Vec::new();
match self.static_material(id) {
Some(material) if !self.own_bodies(id).is_empty() => push_once(&mut names, material.to_string()),
Some(_) => {}
None => {
for body in self.own_bodies(id) {
push_once(&mut names, self.doc.body_material(&body.name));
}
}
}
if holds != Holds::Own {
for child in self.adding(id, holds) {
match self.static_material(child.component) {
Some(material) if child.bom_type != BomType::Phantom => push_once(&mut names, material.to_string()),
_ => {
for name in self.made_of(child.component, holds, depth + 1)? {
push_once(&mut names, name);
}
}
}
}
}
self.made_of.insert((id, holds), names.clone());
Ok(names)
}
/// Each row's mass, and its children's.
fn weigh_rows(&mut self, rows: &mut [Row]) -> Result<()> {
for row in rows {
let weighed = row.components.iter().map(|&c| self.weigh(c, row.stands_for, 0)).collect::<Result<Vec<Weighed>>>()?;
row.mass = agree(&weighed);
self.weigh_rows(&mut row.children)?;
}
Ok(())
}
/// One of `id` as `holds` says: its own bodies, and of what is placed inside it everything that
/// adds to it (see `adds`). Where a part is placed doesn't change what it weighs, so each
/// definition is weighed once for each kind of row and each body measured once. `depth` bounds
/// the walk below a definition not weighed yet.
fn weigh(&mut self, id: u64, holds: Holds, depth: usize) -> Result<Weighed> {
if let Some(&weighed) = self.weighed.get(&(id, holds)) {
return Ok(weighed);
}
if depth > MAX_PLACEMENT_DEPTH {
return Err(too_big());
}
let mut total: Weighed = Some((0.0, 0));
if holds == Holds::Own {
for body in self.own_bodies(id) {
add(&mut total, self.measure(body));
}
} else {
add(&mut total, self.weigh(id, Holds::Own, depth)?);
for child in self.adding(id, holds) {
add(&mut total, self.weigh(child.component, holds, depth + 1)?);
}
}
let weighed = total.filter(|(grams, _)| grams.is_finite());
self.weighed.insert((id, holds), weighed);
Ok(weighed)
}
/// What one body weighs, in grams: its volume, measured once for the model
/// ([`ModelBody::volume`]), scaled by a body offset as the offset scales what is drawn (a
/// damaged file's scaled offset weighs what is drawn).
fn measure(&self, body: &ModelBody) -> Weighed {
let scale = self.doc.model_body_offset(body).map_or(1.0, crate::mat_volume_scale);
// A negative volume (a body inside out) is unknown, never a negative mass.
body.volume().map(|v| v * scale).filter(|v| *v >= 0.0).map(|v| (v * self.doc.density(&body.name) / 1000.0, 1))
}
}
/// The order a level lists its rows in until it is numbered: by part number; rows without one
/// after them, by name; one part's rows by type.
fn list_order(a: &Row, b: &Row) -> Ordering {
(a.part_number.is_empty(), &a.part_number, &a.names, &a.components, a.bom_type).cmp(&(
b.part_number.is_empty(),
&b.part_number,
&b.names,
&b.components,
b.bom_type,
))
}
/// What a row gathers by: a part number, borrowed from its definition, or the definition.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
enum Part<'a> {
Number(&'a str),
Definition(u64),
}
/// How one row is told from another of its level: what it gathers by, the type its parts count
/// as, and its kit: what one of them holds as the row's children read it, to any depth (`Tree::kits`;
/// the same for all that hold nothing listed).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct GroupKey<'a> {
part: Part<'a>,
bom_type: BomType,
kit: u32,
}
/// The placements a list counts, what each folded or all-levels row holds, and each one's kit.
struct Listing {
entries: Vec<Entry>,
/// What each entry holds: the entries listed as its children.
kids: Vec<Vec<usize>>,
kits: Vec<u32>,
}
/// How a walk lists what it passes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Walk {
/// What the view lists.
Listed,
/// Inside an inseparable part in parts only: just its purchased parts, which are listed
/// beside it, at any depth.
PurchasedOnly,
}
/// What a placed component is like, as far as listing it goes.
#[derive(Clone, Copy, Debug, Default)]
struct Shape {
/// It places components of its own.
contents: bool,
/// It has bodies of its own.
bodies: bool,
/// It is a part with a part number in a list that folds.
folded: bool,
/// What it holds is listed under it if it is purchased ([`Request::purchased_contents`], all
/// levels).
open_purchased: bool,
}
/// How one placement counts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Step {
/// It is counted in a row here.
row: bool,
/// How what it holds is walked (`None`: not at all).
inside: Option<Walk>,
/// What it holds is listed as its row's children.
kit: bool,
/// What its row stands for when weighed.
holds: Holds,
}
/// How a placement counts in `view`: the one rule every list and every mass read. A reference
/// counts nowhere and nothing inside it does; a phantom's own bodies, when it has any, are a row of
/// their own (as a subassembly's are in parts only) and what it holds counts where it is placed; a purchased part is one row with nothing inside it listed (in all levels, its
/// contents under it when asked); an inseparable part is one row, and in parts only its purchased
/// parts are listed beside it; a normal part is a row in the structured views, in all levels with
/// what it holds under it, and in parts only a subassembly is listed through its parts unless it
/// has bodies of its own. A folded part is one row holding all it holds, what it holds listed under
/// it (of an inseparable one, its purchased parts).
fn classify(view: View, walk: Walk, bom_type: BomType, shape: Shape) -> Step {
let nothing = Step { row: false, inside: None, kit: false, holds: Holds::All };
let row = |holds| Step { row: true, holds, ..nothing };
let contents = |walk| shape.contents.then_some(walk);
match (bom_type, walk) {
(BomType::Reference, _) => nothing,
(BomType::Phantom, Walk::Listed) => Step { row: shape.bodies, inside: Some(Walk::Listed), kit: false, holds: Holds::Own },
(BomType::Phantom, Walk::PurchasedOnly) => Step { inside: Some(walk), ..nothing },
(BomType::Purchased, Walk::Listed) if view == View::AllLevels && shape.open_purchased => {
Step { inside: contents(Walk::Listed), kit: true, ..row(Holds::All) }
}
(BomType::Purchased, _) => row(Holds::All),
(_, Walk::PurchasedOnly) => Step { inside: contents(Walk::PurchasedOnly), ..nothing },
(BomType::Inseparable, Walk::Listed) if shape.folded => Step { inside: contents(Walk::PurchasedOnly), kit: true, ..row(Holds::All) },
(BomType::Inseparable, Walk::Listed) => match view {
View::PartsOnly => Step { inside: contents(Walk::PurchasedOnly), ..row(Holds::AllButPurchased) },
View::FirstLevel | View::AllLevels => row(Holds::All),
},
(BomType::Normal, Walk::Listed) if shape.folded => Step { inside: contents(Walk::Listed), kit: true, ..row(Holds::All) },
(BomType::Normal, Walk::Listed) => match view {
View::PartsOnly => Step { row: !shape.contents || shape.bodies, inside: contents(Walk::Listed), kit: false, holds: Holds::Own },
View::FirstLevel => row(Holds::All),
View::AllLevels => Step { inside: contents(Walk::Listed), kit: true, ..row(Holds::All) },
},
}
}
/// Whether a placement of `bom_type` inside a part adds to what the part's row stands for: a
/// reference adds nothing, and what the list shows beside the row (what `classify` lists inside
/// an inseparable part) isn't weighed with it.
fn adds(holds: Holds, bom_type: BomType) -> bool {
match holds {
Holds::Own => false,
Holds::All => bom_type != BomType::Reference,
Holds::AllButPurchased => bom_type != BomType::Reference && !classify(View::PartsOnly, Walk::PurchasedOnly, bom_type, Shape::default()).row,
}
}
/// One placement a list counts in a row.
struct Entry {
member: Member,
holds: Holds,
/// The folded placement (a position in the entries) whose children it is among.
kit: Option<usize>,
}
/// Every placement below `request.source` that `request` counts in a row, in one walk under one
/// budget, and with `members` each one's occurrence path (else an empty path each).
fn walk(tree: &Tree, request: &Request, members: bool) -> Result<(Vec<Entry>, Vec<Vec<u64>>)> {
struct Frame {
component: u64,
depth: usize,
/// Kept only with `members`.
path: Vec<u64>,
walk: Walk,
kit: Option<usize>,
}
let mut entries: Vec<Entry> = Vec::new();
let mut paths: Vec<Vec<u64>> = Vec::new();
let mut visited = 0usize;
let mut stack = vec![Frame { component: request.source, depth: 0, path: Vec::new(), walk: Walk::Listed, kit: None }];
while let Some(frame) = stack.pop() {
for placed in tree.contents(frame.component) {
visited += 1;
if frame.depth >= MAX_PLACEMENT_DEPTH || visited > MAX_PLACEMENTS {
return Err(too_big());
}
let component = placed.member.component;
// What a folded part holds is listed as parts only lists it, and isn't folded again; in
// all levels what any row holds is listed in all levels again.
let (view, folds) = match frame.kit {
Some(_) if request.view == View::AllLevels => (View::AllLevels, false),
Some(_) => (View::PartsOnly, false),
None => (request.view, request.fold),
};
let numbered = tree.components.get(&component).is_some_and(|c| c.metadata.part_number().is_some());
let shape = Shape {
contents: tree.children.contains_key(&component),
bodies: tree.bodies.contains_key(&component),
folded: folds && numbered,
open_purchased: request.purchased_contents,
};
let step = classify(view, frame.walk, placed.member.bom_type, shape);
let path = if members { [frame.path.as_slice(), &[placed.occurrence]].concat() } else { Vec::new() };
let here = entries.len();
if step.row {
entries.push(Entry { member: placed.member, holds: step.holds, kit: frame.kit });
paths.push(path.clone());
}
if let Some(walk) = step.inside {
let kit = if step.kit { Some(here) } else { frame.kit };
stack.push(Frame { component, depth: frame.depth + 1, path, walk, kit });
}
}
}
Ok((entries, paths))
}
impl Tree<'_> {
/// Component `id` (0: the design) as a title block shows it (#3875): what one of it weighs, as
/// a list weighs the assembly ([`Total::mass`]: its own bodies and all it holds but references),
/// and what that is made of, as its row would say. Each is worked out once for the tree.
pub fn component(&mut self, id: u64) -> Result<(Mass, Vec<String>)> {
if id != 0 && !self.components.contains_key(&id) {
return Err(DocError::Unknown(format!("component {id}")));
}
let mass = agree(&[self.weigh(id, Holds::All, 0)?]);
Ok((mass, self.made_of(id, Holds::All, 0)?))
}
}
/// The rows `request` lists, with the assembly's total; with `request.mass`, each row and the
/// assembly weighed.
pub fn list(tree: &mut Tree, request: &Request) -> Result<Bom> {
list_with(tree, request, false)
}
/// [`list`], with each row's members when asked (what item numbers and balloons will follow rows
/// by, and what `row_of` reads), so a list made for its rows alone copies no paths.
pub fn list_with(tree: &mut Tree, request: &Request, members: bool) -> Result<Bom> {
if request.source != 0 && !tree.components.contains_key(&request.source) {
return Err(DocError::Unknown(format!("component {}", request.source)));
}
let (entries, mut paths) = walk(tree, request, members)?;
// What each folded placement holds: the placements listed as its children.
let mut kids: Vec<Vec<usize>> = vec![Vec::new(); entries.len()];
for (i, entry) in entries.iter().enumerate() {
if let Some(of) = entry.kit.and_then(|k| kids.get_mut(k)) {
of.push(i);
}
}
let kits = tree.kits(&entries, &kids);
let listing = Listing { entries, kids, kits };
let top: Vec<usize> = listing.entries.iter().enumerate().filter(|(_, e)| e.kit.is_none()).map(|(i, _)| i).collect();
// What a folded part holds is gathered by part number in any list; all levels gathers every
// level as it gathers the first.
let under = if request.view == View::AllLevels { request.group } else { Group::PartNumber };
let mut rows = tree.level(&listing, &mut paths, &top, request.group, under, &[], 1);
let numbered = numbers::apply(&mut rows, numbers::saved(tree.doc, request.source, request.view));
let mut bom = Bom { source: request.source, rows, total: Total::default(), numbered, index: HashMap::new() };
if members {
bom.index_members();
}
tree.name_materials(&mut bom.rows)?;
if request.mass {
tree.weigh_rows(&mut bom.rows)?;
// After the rows, so what their definitions weigh is kept: only what no row holds (the
// assembly's own bodies) is measured again.
bom.total.mass = agree(&[tree.weigh(request.source, Holds::All, 0)?]);
}
let occurrences: u64 = bom.rows_deep().iter().map(|(_, row)| row.count).sum();
bom.total.occurrences = occurrences;
Ok(bom)
}
/// One of a row's parts, from what each of its definitions weighs: known when every one can be
/// measured, has something to weigh, and they agree. Some with something to weigh and some with
/// nothing differ.
fn agree(weighed: &[Weighed]) -> Mass {
let Some(known) = weighed.iter().copied().collect::<Option<Vec<(f64, usize)>>>() else {
return Mass::Unknown(Unknown::Unmeasurable);
};
if known.iter().all(|&(_, bodies)| bodies == 0) {
return Mass::Unknown(Unknown::NothingToWeigh);
}
match known.first() {
Some(&(first, _)) if known.iter().any(|&(grams, bodies)| bodies == 0 || !same_mass(grams, first)) => Mass::Unknown(Unknown::Differs),
Some(&(first, _)) => Mass::Grams(first),
None => Mass::Unknown(Unknown::NothingToWeigh),
}
}
/// How closely the kernel's `measure` gives one solid's volume however it was modelled, relative to
/// it (about 1e-4): a pin made as a cylinder and as a revolved rectangle measured 1.05e-5 apart
/// (2026-10-26), and this is ten times that.
pub const MEASURE_ACCURACY: f64 = 1e-4;
/// Two masses of one part, measured from definitions modelled apart: one when they are within the
/// kernel's measurement accuracy of each other.
fn same_mass(a: f64, b: f64) -> bool {
(a - b).abs() <= MEASURE_ACCURACY * a.abs().max(b.abs())
}
/// `more` added to `total`; unknown when either is. The body count only says whether there is
/// any, so a compact assembly with more placed bodies than a count holds saturates it.
fn add(total: &mut Weighed, more: Weighed) {
*total = total.zip(more).map(|((grams, bodies), (g, n))| (grams + g, bodies.saturating_add(n)));
}
/// `name` added to `names` unless it is there already, so each is listed once, in first order.
fn push_once(names: &mut Vec<String>, name: String) {
if !names.contains(&name) {
names.push(name);
}
}
fn too_big() -> DocError {
crate::assembly::too_many_placements()
}
#[cfg(test)]
#[path = "bom_tests.rs"]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;
-260
View File
@@ -1,260 +0,0 @@
//! Item numbers (#3874). A view of an assembly is numbered once someone numbers it
//! (`assembly.bom.renumber`, `assembly.bom.item.set`): from then on its rows' numbers are saved in
//! the design ([`ItemNumbers`], `assembly.item_numbers`), each under its row's key, so a row keeps
//! its number through part-number edits. Until then a view's numbers are its rows' positions,
//! provisional, and listing it saves nothing.
//!
//! A row a numbered view has no number for (a part added since) shows the highest number saved at
//! its level plus one, provisional, and [`record`] saves it: the engine runs it after each change,
//! in the change's undo step. A row that goes keeps its number until a renumber, so a number on a
//! printed drawing never comes to mean another part, and a part put back gets its number again.
//! A renumber keeps locked numbers and skips them.
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use super::{Group, Member, Request, Row, RowKey, Tree, View, item_text, list, list_order};
use crate::{DocError, Document, MAX_PLACEMENTS, Result};
/// The highest item number.
pub const MAX_ITEM: u32 = 999_999;
/// The item numbers saved for one view of one assembly, with the list options it was numbered
/// with: the rows it numbers are listed by them.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ItemNumbers {
/// The assembly: a component, or 0 for the design.
pub source: u64,
#[serde(rename = "levels")]
pub view: View,
#[serde(default, rename = "group_by")]
pub group: Group,
#[serde(default, rename = "group_children", skip_serializing_if = "std::ops::Not::not")]
pub fold: bool,
#[serde(default, rename = "show_purchased_contents", skip_serializing_if = "std::ops::Not::not")]
pub purchased_contents: bool,
/// One for each row numbered, rows gone since included, by key.
#[serde(default)]
pub items: Vec<ItemNumber>,
}
/// One row's saved item number.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemNumber {
#[serde(flatten)]
pub(crate) key: RowKey,
pub number: u32,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
}
impl ItemNumbers {
/// The list it numbers.
pub fn request(&self) -> Request {
Request { source: self.source, view: self.view, group: self.group, fold: self.fold, mass: false, purchased_contents: self.purchased_contents }
}
}
/// How [`number`] numbers a view.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Numbering {
/// Each row without a saved number keeps the one it shows: the next at its level. Only a
/// numbered view has any to save.
NewRows,
/// Every row from `start` by `step`, level by level, in `order`. Locked rows keep their
/// numbers, which are skipped, and rows gone are forgotten.
Renumber { start: u32, step: u32, order: Order },
/// One row, by its item as the view shows it now (`[1, 2]` is 1.2): its number and its lock.
/// Every other row keeps the number it shows.
Set { row: Vec<u32>, item: Option<u32>, locked: Option<bool> },
}
/// The order a renumber goes in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Order {
/// The list's own, by part number (`list_order`).
#[default]
List,
/// The item numbers', closing the gaps.
Item,
}
/// The numbering saved for a view of an assembly, if it has been numbered.
pub(super) fn saved(doc: &Document, source: u64, view: View) -> Option<&ItemNumbers> {
doc.assembly.item_numbers.iter().find(|n| n.source == source && n.view == view)
}
/// `rows`, as their view lists them, numbered as `saved` says: each its saved number, else the next
/// at its level (provisional); then each level by item number, with duplicates marked. With no
/// numbering they stay as listed, every number provisional. Whether the view is numbered.
pub(super) fn apply(rows: &mut [Row], saved: Option<&ItemNumbers>) -> bool {
let Some(saved) = saved else { return false };
// A damaged file's number out of range is no number, and of a key saved twice the first holds.
let mut numbers: HashMap<&RowKey, &ItemNumber> = HashMap::new();
let mut highest: HashMap<Vec<Member>, u32> = HashMap::new();
for item in saved.items.iter().filter(|i| (1..=MAX_ITEM).contains(&i.number)) {
if numbers.contains_key(&item.key) {
continue;
}
numbers.insert(&item.key, item);
let h = highest.entry(item.key.parents.clone()).or_default();
*h = (*h).max(item.number);
}
number_level(rows, &numbers, &mut highest);
true
}
/// One level of [`apply`], and the levels under it. `highest` is the highest number at each level
/// (by its parents), saved or shown.
fn number_level(rows: &mut [Row], numbers: &HashMap<&RowKey, &ItemNumber>, highest: &mut HashMap<Vec<Member>, u32>) {
for row in rows.iter_mut() {
if let Some(saved) = numbers.get(&row.key) {
row.item = saved.number;
row.locked = saved.locked;
row.provisional = false;
} else {
let h = highest.entry(row.key.parents.clone()).or_default();
*h = h.saturating_add(1);
row.item = *h;
}
number_level(&mut row.children, numbers, highest);
}
rows.sort_by_key(|r| r.item);
let mut uses: HashMap<u32, usize> = HashMap::new();
for row in rows.iter() {
*uses.entry(row.item).or_default() += 1;
}
for row in rows.iter_mut() {
row.duplicate = uses.get(&row.item).is_some_and(|&n| n > 1);
}
}
/// The view `request` asks for, numbered as `how` says: the numbering to save, or `None` when that
/// changes nothing.
pub fn number(tree: &mut Tree, request: &Request, how: Numbering) -> Result<Option<ItemNumbers>> {
let request = Request { mass: false, ..*request };
// Borrowed from the design, not the tree: listing needs the tree, and nothing is copied until
// something changes.
let doc = tree.doc;
let old = saved(doc, request.source, request.view);
let mut bom = list(tree, &request)?;
let mut items: Vec<ItemNumber> = Vec::new();
match how {
Numbering::NewRows => {
let Some(old) = old else { return Ok(None) };
let room = MAX_PLACEMENTS.saturating_sub(old.items.len());
let shown = bom.rows_deep();
let new: Vec<ItemNumber> = shown
.iter()
.filter(|(_, r)| r.provisional && r.item <= MAX_ITEM)
.take(room)
.map(|(_, r)| ItemNumber { key: r.key.clone(), number: r.item, locked: false })
.collect();
if new.is_empty() {
return Ok(None);
}
items = [old.items.as_slice(), &new].concat();
}
Numbering::Set { row, item, locked } => {
if let Some(n) = item.filter(|n| !(1..=MAX_ITEM).contains(n)) {
return Err(DocError::Invalid(format!("an item number is 1 to {MAX_ITEM}, not {n}")));
}
let shown = bom.rows_deep();
let target = shown
.iter()
.find(|(i, _)| *i == row)
.map(|(_, r)| r.key.clone())
.ok_or_else(|| DocError::Unknown(format!("item {}", item_text(&row))))?;
// Rows not listed now keep what was saved for them; listed ones the number they show.
let listed: HashSet<&RowKey> = shown.iter().map(|(_, r)| &r.key).collect();
items.extend(old.iter().flat_map(|o| &o.items).filter(|i| !listed.contains(&i.key)).cloned());
items.extend(shown.iter().map(|(_, r)| ItemNumber { key: r.key.clone(), number: r.item, locked: r.locked }));
if let Some(it) = items.iter_mut().find(|i| i.key == target) {
it.number = item.unwrap_or(it.number);
it.locked = locked.unwrap_or(it.locked);
}
}
Numbering::Renumber { start, step, order } => {
for (name, n) in [("start", start), ("step", step)] {
if !(1..=MAX_ITEM).contains(&n) {
return Err(DocError::Invalid(format!("`{name}` is 1 to {MAX_ITEM}, not {n}")));
}
}
renumber(&mut bom.rows, start, step, order, &mut items)?;
}
}
items.sort_by(|a, b| a.key.cmp(&b.key));
let new = ItemNumbers {
source: request.source,
view: request.view,
group: request.group,
fold: request.fold,
purchased_contents: request.purchased_contents,
items,
};
Ok((old != Some(&new)).then_some(new))
}
/// `rows` and the levels under them numbered from `start` by `step` in `order`, each level on its
/// own, into `out`. Locked rows keep their numbers and the others skip them, so no two rows get one
/// number.
fn renumber(rows: &mut [Row], start: u32, step: u32, order: Order, out: &mut Vec<ItemNumber>) -> Result<()> {
if order == Order::List {
rows.sort_by(list_order);
}
let locked: HashSet<u32> = rows.iter().filter(|r| r.locked).map(|r| r.item).collect();
let mut next = start;
for row in rows.iter_mut() {
if !row.locked {
while locked.contains(&next) {
next = next.saturating_add(step);
}
if next > MAX_ITEM {
return Err(DocError::Invalid(format!("numbering from {start} by {step} passes {MAX_ITEM}")));
}
row.item = next;
next = next.saturating_add(step);
}
out.push(ItemNumber { key: row.key.clone(), number: row.item, locked: row.locked });
renumber(&mut row.children, start, step, order, out)?;
}
Ok(())
}
/// Each numbered view of the design with its new rows' numbers saved ([`Numbering::NewRows`]), and
/// a component's views dropped once it is gone: the design's numberings to save, or `None` when
/// none changed. A view that can't be listed now (too large) is left as it is. A view numbered
/// twice (only a damaged file is) keeps its first numbering, the one listing reads.
pub fn record(tree: &mut Tree) -> Option<Vec<ItemNumbers>> {
let doc = tree.doc;
// Each numbering that changes: its new self, or none when its component is gone or its view
// already has one, so no view is listed twice after a change.
let mut changes: Vec<(usize, Option<ItemNumbers>)> = Vec::new();
let mut views = HashSet::new();
for (i, numbering) in doc.assembly.item_numbers.iter().enumerate() {
let gone = numbering.source != 0 && !tree.components.contains_key(&numbering.source);
if gone || !views.insert((numbering.source, numbering.view)) {
changes.push((i, None));
} else if let Ok(Some(new)) = number(tree, &numbering.request(), Numbering::NewRows) {
changes.push((i, Some(new)));
}
}
if changes.is_empty() {
return None;
}
let mut out = doc.assembly.item_numbers.clone();
for (i, change) in changes.into_iter().rev() {
match change {
Some(new) => {
if let Some(numbering) = out.get_mut(i) {
*numbering = new;
}
}
None => {
out.remove(i);
}
}
}
Some(out)
}
-101
View File
@@ -1,101 +0,0 @@
//! Quantities (#3874): what one placement of a component means in a bill of materials, typed in as
//! its [`BOM_QUANTITY`](crate::metadata::BOM_QUANTITY) part property, and what a row's placements
//! add up to. The property is an expression evaluated with the design's parameters, so a cable's
//! length follows the design; without a unit it is a number of pieces (a pack of 4). Units come
//! from expressions: mass, volume and area wait for their expression units (#4811).
use std::cell::OnceCell;
use std::collections::BTreeMap;
use crate::expr::{self, Value};
use crate::{DocError, Document, Result};
/// The largest quantity of one placement, in pieces or millimetres.
const MAX_AMOUNT: f64 = 1e9;
/// The mass and volume units of #4811's expressions: until they reach expressions, a quantity
/// naming one is refused for what it is, not as an unknown parameter.
const MASS_AND_VOLUME: [&str; 7] = ["g", "kg", "lb", "ml", "mm³", "cm³", "m³"];
/// What one placement of a component counts.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Amount {
Pieces(f64),
/// Millimetres.
Length(f64),
}
/// A row's quantity: what its placements add up to.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum Quantity {
/// None of its definitions has a quantity: its count says it all.
#[default]
Count,
Pieces(f64),
/// Millimetres.
Length(f64),
Unknown(Uncounted),
}
/// Why a row's quantity isn't known.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Uncounted {
/// Pieces and lengths can't be added (a placement without a quantity is one piece).
Mixed,
/// A definition's quantity doesn't evaluate: a parameter it names is in error (or missing from
/// a damaged file).
Invalid,
}
/// Evaluate a quantity expression with `doc`'s parameters.
pub fn amount(doc: &Document, text: &str) -> Result<Amount> {
amount_in(doc, &OnceCell::new(), text)
}
/// [`amount`] with the parameters' values shared between calls, evaluated only when an expression
/// names one.
pub(super) fn amount_in(doc: &Document, values: &OnceCell<BTreeMap<String, Value>>, text: &str) -> Result<Amount> {
let value = expr::eval_with(text, &|name| match values.get_or_init(|| doc.param_values().0).get(name) {
Some(v) => Ok(*v),
None if MASS_AND_VOLUME.contains(&name) => Err(mass_and_volume()),
None => Err(DocError::Expr(format!("unknown parameter `{name}`"))),
})?;
let amount = match (value.len, value.ang) {
(0, 0) => Amount::Pieces(value.v),
(1, 0) => Amount::Length(value.v),
(2, 0) => return Err(DocError::Invalid("not supported yet: area quantities".into())),
(3, 0) => return Err(mass_and_volume()),
_ => return Err(DocError::Invalid(format!("a quantity is a number of pieces or a length, not {}", value.describe()))),
};
let (Amount::Pieces(v) | Amount::Length(v)) = amount;
if !(v > 0.0 && v <= MAX_AMOUNT) {
return Err(DocError::Invalid(format!("a quantity is above 0 and at most {MAX_AMOUNT:e}")));
}
Ok(amount)
}
fn mass_and_volume() -> DocError {
DocError::Invalid("not supported yet: mass and volume quantities need the expression units of #4811".into())
}
/// What placements add up to, from each one's entry in the definitions' amounts: none without a
/// quantity (one piece), `Some(None)` when its quantity doesn't evaluate.
pub(super) fn add_up<'a>(amounts: impl IntoIterator<Item = Option<&'a Option<Amount>>>) -> Quantity {
let (mut pieces, mut length, mut typed) = (None, None, false);
for amount in amounts {
let (sum, n): (&mut Option<f64>, f64) = match amount {
None => (&mut pieces, 1.0),
Some(None) => return Quantity::Unknown(Uncounted::Invalid),
Some(Some(Amount::Pieces(n))) => (&mut pieces, *n),
Some(Some(Amount::Length(mm))) => (&mut length, *mm),
};
typed |= amount.is_some();
*sum.get_or_insert(0.0) += n;
}
match (pieces, length) {
_ if !typed => Quantity::Count,
(Some(_), Some(_)) => Quantity::Unknown(Uncounted::Mixed),
(None, Some(mm)) => Quantity::Length(mm),
(n, None) => Quantity::Pieces(n.unwrap_or_default()),
}
}
-922
View File
@@ -1,922 +0,0 @@
use serde_json::json;
use super::*;
use crate::BomType::{Inseparable, Normal, Phantom, Purchased, Reference};
use crate::{IDENTITY, Occurrence};
/// A new component placed once in `parent`, and that placement.
fn add(doc: &mut Document, name: &str, parent: u64) -> (u64, u64) {
let component = doc.add_component(Some(name), parent).unwrap();
(component, doc.occurrences.last().unwrap().id)
}
/// Another placement of `component` in `parent`.
fn place(doc: &mut Document, component: u64, parent: u64) -> u64 {
doc.add_occurrence(component, parent, IDENTITY).unwrap()
}
/// A part property as a file holds it (reading never validates).
fn set(doc: &mut Document, component: u64, key: &str, value: &str) {
doc.components.iter_mut().find(|c| c.id == component).unwrap().metadata.0.insert(key.into(), json!(value));
}
fn number(doc: &mut Document, component: u64, part_number: &str) {
set(doc, component, "part_number", part_number);
}
fn typed(doc: &mut Document, component: u64, bom_type: BomType) {
set(doc, component, "bom_type", bom_type.as_str());
}
/// What `request` lists for `doc` (none of whose components has bodies), with each row's members.
fn listed(doc: &Document, request: Request) -> Result<Bom> {
let state = ModelState::default();
list_with(&mut Tree::new(doc, &state), &request, true)
}
fn view(view: View) -> Request {
Request { view, ..Request::default() }
}
fn folded(view: View) -> Request {
Request { view, fold: true, ..Request::default() }
}
/// Each row, followed by the rows under it: item, names, type, count and each.
fn rows(bom: &Bom) -> Vec<(String, String, BomType, u64, u64)> {
bom.rows_deep().into_iter().map(|(item, r)| (item_text(&item), r.names.join("; "), r.bom_type, r.count, r.each)).collect()
}
fn row(item: &str, names: &str, bom_type: BomType, count: u64, each: u64) -> (String, String, BomType, u64, u64) {
(item.into(), names.into(), bom_type, count, each)
}
#[test]
fn the_view_and_grouping_words_are_the_commands() {
for v in View::ALL {
assert_eq!(View::parse(v.as_str()), Some(v));
}
for g in Group::ALL {
assert_eq!(Group::parse(g.as_str()), Some(g));
}
assert_eq!((View::default().as_str(), Group::default().as_str()), ("flat", "part_number"), "what assembly.bom lists by default");
for word in ["", "deep", "Flat", "part number"] {
assert_eq!((View::parse(word), Group::parse(word)), (None, None), "{word}");
}
}
#[test]
fn an_empty_assembly_lists_nothing_and_weighs_nothing() {
let mut doc = Document::default();
let empty = listed(&doc, Request { mass: true, ..Request::default() }).unwrap();
assert!(empty.rows.is_empty());
assert_eq!(empty.total, Total { occurrences: 0, mass: Mass::Unknown(Unknown::NothingToWeigh) });
assert_eq!(listed(&doc, Request::default()).unwrap().total.mass, Mass::NotAsked, "weighed only when asked");
// A component that holds nothing, as the assembly listed; one that isn't there is refused.
let (part, _) = add(&mut doc, "Part", 0);
for v in View::ALL {
assert!(listed(&doc, Request { source: part, view: v, ..Request::default() }).unwrap().rows.is_empty());
}
assert!(listed(&doc, Request { source: 999, ..Request::default() }).is_err());
}
#[test]
fn one_part_is_one_row_named_by_its_lowest_member() {
let mut doc = Document::default();
let (part, occurrence) = add(&mut doc, "Part", 0);
for (v, stands_for) in [(View::PartsOnly, Holds::Own), (View::FirstLevel, Holds::All)] {
let bom = listed(&doc, view(v)).unwrap();
let [r] = bom.rows.as_slice() else { panic!("{bom:?}") };
assert_eq!(r.key, RowKey { parents: vec![], member: Member { component: part, bom_type: Normal } });
assert_eq!((r.item, r.count, r.each, r.stands_for, r.mass), (1, 1, 1, stands_for, Mass::NotAsked));
assert_eq!((&r.components, &r.names, &r.members), (&vec![part], &vec!["Part".to_string()], &vec![vec![occurrence]]));
assert!(r.children.is_empty() && r.materials.is_empty() && r.part_number.is_empty());
assert_eq!(bom.total.occurrences, 1);
}
}
#[test]
fn a_row_is_named_by_its_lowest_member_not_by_its_part_number() {
let mut doc = Document::default();
let (a, oa) = add(&mut doc, "A", 0);
let (b, ob) = add(&mut doc, "B", 0);
number(&mut doc, a, "P");
number(&mut doc, b, "P");
let bom = listed(&doc, Request::default()).unwrap();
let [r] = bom.rows.as_slice() else { panic!("{bom:?}") };
assert_eq!((r.key.member.component, &r.components, &r.members, r.part_number.as_str()), (a, &vec![a, b], &vec![vec![oa], vec![ob]], "P"));
// Renumbering the lower one: its row goes with it, and the other's row is named by the other.
number(&mut doc, a, "Q");
let bom = listed(&doc, Request::default()).unwrap();
let keys: Vec<(&str, u64)> = bom.rows.iter().map(|r| (r.part_number.as_str(), r.key.member.component)).collect();
assert_eq!(keys, [("P", b), ("Q", a)]);
}
#[test]
fn one_part_number_counted_as_two_types_is_two_rows() {
let mut doc = Document::default();
let (a, _) = add(&mut doc, "Made", 0);
let (b, _) = add(&mut doc, "Bought", 0);
number(&mut doc, a, "P");
number(&mut doc, b, "P");
typed(&mut doc, b, Purchased);
// A second placement of the made one, counted as bought: it joins the bought row.
let second = place(&mut doc, a, 0);
doc.occurrences.iter_mut().find(|o| o.id == second).unwrap().bom_type = Some(Purchased);
for v in View::ALL {
let bom = listed(&doc, view(v)).unwrap();
assert_eq!(rows(&bom), [row("1", "Made", Normal, 1, 1), row("2", "Made; Bought", Purchased, 2, 2)], "{v:?}");
let keys: Vec<Member> = bom.rows.iter().map(|r| r.key.member).collect();
assert_eq!(keys, [Member { component: a, bom_type: Normal }, Member { component: a, bom_type: Purchased }]);
}
}
#[test]
fn a_phantom_in_a_phantom_promotes_what_it_holds_into_the_level_it_sits_in() {
let mut doc = Document::default();
let (kit, okit) = add(&mut doc, "Kit", 0);
let (set_, oset) = add(&mut doc, "Set", kit);
let oset2 = place(&mut doc, set_, kit);
let (screw, oscrew) = add(&mut doc, "Screw", set_);
let oscrew2 = place(&mut doc, screw, set_);
let alone = place(&mut doc, screw, 0);
typed(&mut doc, kit, Phantom);
typed(&mut doc, set_, Phantom);
let mut paths = vec![vec![okit, oset, oscrew], vec![okit, oset, oscrew2], vec![okit, oset2, oscrew], vec![okit, oset2, oscrew2], vec![alone]];
paths.sort();
for v in View::ALL {
let bom = listed(&doc, view(v)).unwrap();
assert_eq!(rows(&bom), [row("1", "Screw", Normal, 5, 5)], "{v:?}");
assert_eq!(bom.rows[0].members, paths);
}
}
#[test]
fn a_reference_counts_nowhere_and_its_own_list_lists_what_it_holds() {
let mut doc = Document::default();
let (fixture, ofixture) = add(&mut doc, "Fixture", 0);
add(&mut doc, "Pin", fixture);
typed(&mut doc, fixture, Reference);
for v in View::ALL {
let bom = listed(&doc, Request { mass: true, ..view(v) }).unwrap();
assert_eq!((bom.rows.len(), bom.total.occurrences), (0, 0), "{v:?}");
assert_eq!(bom.total.mass, Mass::Unknown(Unknown::NothingToWeigh));
assert_eq!(rows(&listed(&doc, Request { source: fixture, view: v, ..Request::default() }).unwrap()), [row("1", "Pin", Normal, 1, 1)]);
}
// An occurrence counted as a reference, its component normal: the same.
set(&mut doc, fixture, "bom_type", "normal");
assert_eq!(rows(&listed(&doc, Request::default()).unwrap()), [row("1", "Pin", Normal, 1, 1)]);
doc.occurrences.iter_mut().find(|o| o.id == ofixture).unwrap().bom_type = Some(Reference);
assert!(listed(&doc, Request::default()).unwrap().rows.is_empty());
}
#[test]
fn a_list_walks_64_levels_and_refuses_a_65th() {
let mut doc = Document::default();
let mut parent = 0;
for level in 1..=64 {
parent = add(&mut doc, &format!("L{level}"), parent).0;
}
let bom = listed(&doc, Request::default()).unwrap();
assert_eq!(rows(&bom), [row("1", "L64", Normal, 1, 1)]);
assert_eq!(bom.rows[0].members[0].len(), 64);
add(&mut doc, "L65", parent);
assert!(listed(&doc, Request::default()).is_err(), "refused, never listed in part");
// The first level only walks what the design places directly.
assert_eq!(rows(&listed(&doc, view(View::FirstLevel)).unwrap()), [row("1", "L1", Normal, 1, 1)]);
}
#[test]
fn one_placement_budget_covers_the_whole_list_folded_parts_included() {
let mut doc = Document::default();
let (a, _) = add(&mut doc, "A", 0);
for _ in 1..1000 {
place(&mut doc, a, 0);
}
let (b, _) = add(&mut doc, "B", a);
for _ in 1..99 {
place(&mut doc, b, a);
}
// 1,000 As holding 99 Bs each: 100,000 placements, as many as one list walks.
let flat = listed(&doc, Request::default()).unwrap();
assert_eq!((rows(&flat), flat.total.occurrences), (vec![row("1", "B", Normal, 99_000, 99_000)], 99_000));
number(&mut doc, a, "A-1");
let kit = listed(&doc, folded(View::PartsOnly)).unwrap();
assert_eq!((rows(&kit), kit.total.occurrences), (vec![row("1", "A", Normal, 1000, 1000), row("1.1", "B", Normal, 99_000, 99)], 100_000));
let (a_row, b_row) = (&kit.rows[0], &kit.rows[0].children[0]);
assert_eq!(b_row.key, RowKey { parents: vec![a_row.key.member], member: Member { component: b, bom_type: Normal } });
assert_eq!(a_row.stands_for, Holds::All, "a folded part's row is the kit");
// One placement more, anywhere, is refused, folded or not: a folded part's contents are walked
// under the list's budget, not one of their own.
add(&mut doc, "C", 0);
for request in [Request::default(), folded(View::PartsOnly), folded(View::FirstLevel)] {
assert!(listed(&doc, request).is_err(), "{request:?}");
}
assert_eq!(listed(&doc, view(View::FirstLevel)).unwrap().total.occurrences, 1001, "the first level walks 1,001");
}
/// Kits used to be compared as text (`"#12×1"`): a part without a part number, whose id is 12,
/// read the same as a part numbered "#12", and a part numbered "P×1\nQ" the same as a P and a Q.
#[test]
fn kits_are_told_apart_by_what_they_hold_not_by_how_it_reads() {
let mut doc = Document::default();
let bolt = |doc: &mut Document, name: &str| {
let (id, _) = add(doc, name, 0);
number(doc, id, "BOLT");
id
};
let (b1, b2, b3, b4) = (bolt(&mut doc, "Bolt 1"), bolt(&mut doc, "Bolt 2"), bolt(&mut doc, "Bolt 3"), bolt(&mut doc, "Bolt 4"));
let (plain, _) = add(&mut doc, "Plain", b1);
let (spelled, _) = add(&mut doc, "Spelled", b2);
number(&mut doc, spelled, &format!("#{plain}"));
let (pq, _) = add(&mut doc, "PQ", b3);
number(&mut doc, pq, "P×1\nQ");
let (p, _) = add(&mut doc, "P", b4);
let (q, _) = add(&mut doc, "Q", b4);
number(&mut doc, p, "P");
number(&mut doc, q, "Q");
let bolts = |doc: &Document| -> Vec<Vec<u64>> {
let bom = listed(doc, folded(View::PartsOnly)).unwrap();
bom.rows.iter().filter(|r| r.part_number == "BOLT").map(|r| r.components.clone()).collect()
};
let mut each: Vec<Vec<u64>> = bolts(&doc);
each.sort();
assert_eq!(each, [vec![b1], vec![b2], vec![b3], vec![b4]], "four kits, four rows");
// The same parts by part number are one kit, whichever definitions they are.
let b5 = bolt(&mut doc, "Bolt 5");
let (p2, _) = add(&mut doc, "P again", b5);
let (q2, _) = add(&mut doc, "Q again", b5);
number(&mut doc, p2, "P");
number(&mut doc, q2, "Q");
assert!(bolts(&doc).contains(&vec![b4, b5]));
let bom = listed(&doc, folded(View::PartsOnly)).unwrap();
let both = bom.rows.iter().find(|r| r.components == [b4, b5]).unwrap();
let inside: Vec<(&str, &[u64], u64, u64)> =
both.children.iter().map(|c| (c.part_number.as_str(), c.components.as_slice(), c.each, c.count)).collect();
assert_eq!(inside, [("P", &[p, p2][..], 1, 2), ("Q", &[q, q2][..], 1, 2)], "each bolt's, counted for both");
// The same parts counted as another type are another kit: a bolt whose Q is bought is a row of its own.
let b6 = bolt(&mut doc, "Bolt 6");
let (p3, _) = add(&mut doc, "P third", b6);
let (q3, bought) = add(&mut doc, "Q third", b6);
number(&mut doc, p3, "P");
number(&mut doc, q3, "Q");
doc.occurrences.iter_mut().find(|o| o.id == bought).unwrap().bom_type = Some(Purchased);
let each = bolts(&doc);
assert!(each.contains(&vec![b4, b5]) && each.contains(&vec![b6]), "{each:?}");
}
/// All levels walks under the list's one budget: 100,000 placements and 64 levels are listed, and
/// one more of either is refused, never listed in part.
#[test]
fn all_levels_lists_at_each_cap_and_refuses_one_more() {
let mut doc = Document::default();
let (part, _) = add(&mut doc, "Part", 0);
let base = doc.occurrences.iter().map(|o| o.id).max().unwrap_or(0);
let placement =
|id: u64| Occurrence { id, component: part, parent: 0, name: format!("Part:{id}"), transform: IDENTITY, grounded: false, bom_type: None };
doc.occurrences.extend((1..MAX_PLACEMENTS as u64).map(|i| placement(base + i)));
let all = listed(&doc, view(View::AllLevels)).unwrap();
assert_eq!((all.rows.len(), all.total.occurrences), (1, MAX_PLACEMENTS as u64));
doc.occurrences.push(placement(base + MAX_PLACEMENTS as u64));
assert!(listed(&doc, view(View::AllLevels)).is_err(), "100,001 placements");
let mut doc = Document::default();
let mut parent = 0;
for i in 0..MAX_PLACEMENT_DEPTH {
parent = add(&mut doc, &format!("L{i}"), parent).0;
}
let all = listed(&doc, view(View::AllLevels)).unwrap();
assert_eq!(all.rows_deep().last().map(|(item, _)| item.len()), Some(MAX_PLACEMENT_DEPTH));
add(&mut doc, "Too deep", parent);
assert!(listed(&doc, view(View::AllLevels)).is_err(), "65 levels");
}
/// Two parts with one part number are one row only when what they hold is the same all the way
/// down: here they differ two levels below, so in all levels they are two rows, each with its own.
#[test]
fn kits_are_compared_to_any_depth() {
let mut doc = Document::default();
let (a, _) = add(&mut doc, "A", 0);
let (b, _) = add(&mut doc, "B", 0);
let (x1, _) = add(&mut doc, "X1", a);
let (x2, _) = add(&mut doc, "X2", b);
let (bolt, _) = add(&mut doc, "Bolt", x1);
for (id, part_number) in [(a, "P"), (b, "P"), (x1, "X"), (x2, "X"), (bolt, "BOLT")] {
number(&mut doc, id, part_number);
}
let all = listed(&doc, view(View::AllLevels)).unwrap();
assert_eq!(
rows(&all),
[
row("1", "A", Normal, 1, 1),
row("1.1", "X1", Normal, 1, 1),
row("1.1.1", "Bolt", Normal, 1, 1),
row("2", "B", Normal, 1, 1),
row("2.1", "X2", Normal, 1, 1)
],
"two P rows, never one with children at each 0"
);
// The same contents two levels down: one row again.
let (bolt2, _) = add(&mut doc, "Bolt 2", x2);
number(&mut doc, bolt2, "BOLT");
let all = listed(&doc, view(View::AllLevels)).unwrap();
assert_eq!(rows(&all), [row("1", "A; B", Normal, 2, 2), row("1.1", "X1; X2", Normal, 2, 1), row("1.1.1", "Bolt; Bolt 2", Normal, 2, 1)]);
}
/// What a folded part holds is listed as parts only lists it, in parts only and the first level,
/// and isn't folded again: a numbered subassembly inside it is listed through its parts. In all
/// levels it is listed in all levels again, so that subassembly is a row with its own rows.
#[test]
fn what_a_folded_part_holds_is_listed_as_parts_only_lists_it() {
let mut doc = Document::default();
let (machine, _) = add(&mut doc, "Machine", 0);
let (gearbox, _) = add(&mut doc, "Gearbox", machine);
let (shaft, _) = add(&mut doc, "Shaft", gearbox);
place(&mut doc, shaft, gearbox);
add(&mut doc, "Pin", gearbox);
for (id, part_number) in [(machine, "M-1"), (gearbox, "GB-1"), (shaft, "SH-1")] {
number(&mut doc, id, part_number);
}
for v in [View::PartsOnly, View::FirstLevel] {
let bom = listed(&doc, folded(v)).unwrap();
assert_eq!(rows(&bom), [row("1", "Machine", Normal, 1, 1), row("1.1", "Shaft", Normal, 2, 2), row("1.2", "Pin", Normal, 1, 1)], "{v:?}");
}
let all = listed(&doc, folded(View::AllLevels)).unwrap();
let nested = [
row("1", "Machine", Normal, 1, 1),
row("1.1", "Gearbox", Normal, 1, 1),
row("1.1.1", "Shaft", Normal, 2, 2),
row("1.1.2", "Pin", Normal, 1, 1),
];
assert_eq!(rows(&all), nested);
}
/// Bolt (purchased) holding a nut; Frame (inseparable) holding a plate, a purchased clip and a
/// weld kit (phantom) of a rivet and a purchased insert; Fixture (reference) holding a pin; Set
/// (phantom) holding a screw; Sub (normal, numbered, no bodies) holding a washer. Each is placed
/// once.
fn mixed() -> (Document, BTreeMap<&'static str, u64>) {
let mut doc = Document::default();
let mut at = BTreeMap::new();
for (name, parent, bom_type) in [
("Bolt", "", Purchased),
("Nut", "Bolt", Normal),
("Frame", "", Inseparable),
("Plate", "Frame", Normal),
("Clip", "Frame", Purchased),
("Weld kit", "Frame", Phantom),
("Rivet", "Weld kit", Normal),
("Insert", "Weld kit", Purchased),
("Fixture", "", Reference),
("Pin", "Fixture", Normal),
("Set", "", Phantom),
("Screw", "Set", Normal),
("Sub", "", Normal),
("Washer", "Sub", Normal),
] {
let parent = doc.find_component(parent).unwrap_or(0);
let (id, occurrence) = add(&mut doc, name, parent);
typed(&mut doc, id, bom_type);
at.insert(name, occurrence);
}
let sub = doc.find_component("Sub").unwrap();
number(&mut doc, sub, "SUB-1");
(doc, at)
}
#[test]
fn row_of_finds_the_row_a_placement_is_counted_in() {
let (doc, at) = mixed();
let path = |names: &[&str]| -> Vec<u64> { names.iter().map(|n| at[*n]).collect() };
let state = ModelState::default();
let mut tree = Tree::new(&doc, &state);
let name = |r: std::result::Result<(Vec<u32>, &Row), Unlisted>| r.map(|(_, r)| r.names.join("; "));
let flat = list_with(&mut tree, &Request::default(), true).unwrap();
// Made for its rows alone, a list keeps no members.
assert!(list(&mut tree, &Request::default()).unwrap().rows.iter().all(|r| r.members.is_empty()));
let cases: &[(&[&str], std::result::Result<&str, Unlisted>)] = &[
(&["Bolt"], Ok("Bolt")),
(&["Bolt", "Nut"], Ok("Bolt")),
(&["Frame"], Ok("Frame")),
(&["Frame", "Plate"], Ok("Frame")),
(&["Frame", "Clip"], Ok("Clip")),
(&["Frame", "Weld kit"], Ok("Frame")),
(&["Frame", "Weld kit", "Rivet"], Ok("Frame")),
(&["Frame", "Weld kit", "Insert"], Ok("Insert")),
(&["Fixture"], Err(Unlisted::Reference)),
(&["Fixture", "Pin"], Err(Unlisted::Reference)),
(&["Set"], Err(Unlisted::NoRow)),
(&["Set", "Screw"], Ok("Screw")),
(&["Sub"], Err(Unlisted::NoRow)),
(&["Sub", "Washer"], Ok("Washer")),
(&[], Err(Unlisted::NoRow)),
(&["Nut"], Err(Unlisted::NotFound)),
];
for (names, found) in cases {
assert_eq!(name(flat.row_of(&tree, &path(names))), found.map(String::from), "{names:?}");
}
assert_eq!(flat.row_of(&tree, &[999]).err(), Some(Unlisted::NotFound));
assert_eq!(flat.row_of(&tree, &vec![at["Bolt"]; 65]).err(), Some(Unlisted::NotFound));
let stands: Vec<(String, Holds)> = flat.rows.iter().map(|r| (r.names.join("; "), r.stands_for)).collect();
let want = [
("Bolt", Holds::All),
("Clip", Holds::All),
("Frame", Holds::AllButPurchased),
("Insert", Holds::All),
("Screw", Holds::Own),
("Washer", Holds::Own),
];
assert_eq!(stands, want.map(|(n, h)| (n.to_string(), h)));
let top = list_with(&mut tree, &view(View::FirstLevel), true).unwrap();
let cases: &[(&[&str], &str)] = &[(&["Sub", "Washer"], "Sub"), (&["Set", "Screw"], "Screw"), (&["Frame", "Clip"], "Frame")];
for (names, found) in cases {
assert_eq!(name(top.row_of(&tree, &path(names))), Ok(found.to_string()), "{names:?}");
}
assert!(top.rows.iter().all(|r| r.stands_for == Holds::All));
let kit = list_with(&mut tree, &folded(View::PartsOnly), true).unwrap();
let (_, sub) = kit.row_of(&tree, &path(&["Sub"])).unwrap();
let (_, washer) = kit.row_of(&tree, &path(&["Sub", "Washer"])).unwrap();
assert_eq!((sub.names.join(""), sub.stands_for, washer.names.join("")), ("Sub".into(), Holds::All, "Washer".into()));
assert_eq!(washer.key.parents, [sub.key.member]);
}
/// Each placement is counted in one row or none, every view and grouping: the counts, the
/// members and the total agree, and `row_of` finds each member's row.
#[test]
fn every_placement_is_in_one_row_or_none() {
let (mut doc, _) = mixed();
// More of the same: the subassembly and the bolt placed again, the washer twice in the subassembly.
for name in ["Sub", "Bolt"] {
let id = doc.find_component(name).unwrap();
place(&mut doc, id, 0);
}
let (washer, sub) = (doc.find_component("Washer").unwrap(), doc.find_component("Sub").unwrap());
place(&mut doc, washer, sub);
let state = ModelState::default();
let mut tree = Tree::new(&doc, &state);
for v in View::ALL {
for fold in [false, true] {
for group in Group::ALL {
let request = Request { view: v, fold, group, ..Request::default() };
let bom = list_with(&mut tree, &request, true).unwrap();
let deep = bom.rows_deep();
let mut seen: Vec<&Vec<u64>> = deep.iter().flat_map(|(_, r)| &r.members).collect();
assert_eq!(seen.len() as u64, bom.total.occurrences, "{request:?}");
assert!(deep.iter().all(|(_, r)| r.count == r.members.len() as u64), "{request:?}");
seen.sort();
seen.dedup();
assert_eq!(seen.len() as u64, bom.total.occurrences, "a placement in two rows: {request:?}");
for (_, r) in &deep {
for m in &r.members {
assert_eq!(bom.row_of(&tree, m).map(|(_, found)| &found.key), Ok(&r.key), "{request:?} {m:?}");
}
}
}
}
}
}
#[test]
fn a_damaged_files_structure_is_listed_or_refused_never_followed_forever() {
let mut doc = Document::default();
let (a, _) = add(&mut doc, "A", 0);
let ghost =
|id, component, parent| Occurrence { id, component, parent, name: format!("{id}"), transform: IDENTITY, grounded: false, bom_type: None };
// An occurrence of a component that isn't there has no row and counts nowhere.
doc.occurrences.push(ghost(900, 999, 0));
assert_eq!(rows(&listed(&doc, Request::default()).unwrap()), [row("1", "A", Normal, 1, 1)]);
// A component placed inside itself: refused at the depth limit, never walked forever.
doc.occurrences.push(ghost(901, a, a));
assert!(listed(&doc, Request::default()).is_err());
// At the first level A is one row, but its material and its mass are what it holds: both walk inside.
assert!(listed(&doc, Request { mass: true, ..view(View::FirstLevel) }).is_err(), "weighing it walks inside");
assert!(listed(&doc, view(View::FirstLevel)).is_err(), "naming its material walks inside");
}
/// Moved from the engine's tests with the rule (it was `row_mass`), now with the reason.
#[test]
fn a_row_has_a_mass_only_when_its_definitions_are_weighed_and_agree() {
let (steel, aluminium, surface, empty): (Weighed, Weighed, Weighed, Weighed) =
(Some((392.5, 1)), Some((6.48, 1)), Some((0.0, 1)), Some((0.0, 0)));
assert_eq!(agree(&[steel, steel]), Mass::Grams(392.5));
assert_eq!(agree(&[steel, aluminium]), Mass::Unknown(Unknown::Differs));
assert_eq!(agree(&[surface, surface]), Mass::Grams(0.0), "a body that weighs nothing (a surface) is weighed");
assert_eq!(agree(&[surface, steel]), Mass::Unknown(Unknown::Differs));
assert_eq!(agree(&[empty]), Mass::Unknown(Unknown::NothingToWeigh), "nothing to weigh");
assert_eq!(agree(&[steel, empty]), Mass::Unknown(Unknown::Differs), "one weighs something, one nothing");
assert_eq!(agree(&[empty, steel]), Mass::Unknown(Unknown::Differs), "whichever comes first");
assert_eq!(agree(&[empty, empty]), Mass::Unknown(Unknown::NothingToWeigh));
assert_eq!(agree(&[None]), Mass::Unknown(Unknown::Unmeasurable), "not measured");
assert_eq!(agree(&[aluminium, None, steel]), Mass::Unknown(Unknown::Unmeasurable), "whichever definition it is");
assert_eq!(agree(&[]), Mass::Unknown(Unknown::NothingToWeigh));
}
/// What a row's part stands for leaves out a reference, and an inseparable part's purchased parts,
/// which its list shows beside it; phantoms and inseparable parts inside are weighed through.
#[test]
fn what_adds_to_a_row_follows_what_it_stands_for() {
for t in BomType::ALL {
assert!(!adds(Holds::Own, t), "{t:?}");
assert_eq!(adds(Holds::All, t), t != Reference, "{t:?}");
assert_eq!(adds(Holds::AllButPurchased, t), !matches!(t, Reference | Purchased), "{t:?}");
}
}
/// `how` applied to the view `request` lists, its numbering saved as the engine saves it; whether
/// anything changed.
fn numbered(doc: &mut Document, request: Request, how: Numbering) -> bool {
let state = ModelState::default();
let Some(numbers) = super::number(&mut Tree::new(doc, &state), &request, how).unwrap() else { return false };
let saved = &mut doc.assembly.item_numbers;
saved.retain(|n| (n.source, n.view) != (numbers.source, numbers.view));
saved.push(numbers);
true
}
/// [`record`] as the engine runs it after a change; whether anything changed.
fn recorded(doc: &mut Document) -> bool {
let state = ModelState::default();
let Some(numbers) = record(&mut Tree::new(doc, &state)) else { return false };
doc.assembly.item_numbers = numbers;
true
}
const RENUMBER: Numbering = Numbering::Renumber { start: 1, step: 1, order: Order::List };
/// Each row, followed by the rows under it: item, names, and its numbering flags as letters
/// (provisional, locked, duplicate).
fn items(bom: &Bom) -> Vec<(String, String, String)> {
bom.rows_deep()
.into_iter()
.map(|(item, r)| {
let flags = [(r.provisional, 'p'), (r.locked, 'l'), (r.duplicate, 'd')].iter().filter(|(on, _)| *on).map(|(_, c)| *c).collect();
(item_text(&item), r.names.join("; "), flags)
})
.collect()
}
fn item(item: &str, names: &str, flags: &str) -> (String, String, String) {
(item.into(), names.into(), flags.into())
}
/// Parts A, B and C at the top, part numbers P1 to P3.
fn three_parts() -> (Document, [(u64, u64); 3]) {
let mut doc = Document::default();
let parts = ["A", "B", "C"].map(|name| add(&mut doc, name, 0));
for (i, (part, _)) in parts.iter().enumerate() {
number(&mut doc, *part, &format!("P{}", i + 1));
}
(doc, parts)
}
#[test]
fn numbering_a_view_saves_its_numbers_and_new_rows_take_the_next() {
let (mut doc, [_, (b, ob), _]) = three_parts();
// Never numbered: positions, provisional, and nothing saved by a change.
let bom = listed(&doc, Request::default()).unwrap();
assert!(!bom.numbered);
assert_eq!(items(&bom), [item("1", "A", "p"), item("2", "B", "p"), item("3", "C", "p")]);
assert!(!recorded(&mut doc), "a design never numbered saves nothing");
assert!(numbered(&mut doc, Request::default(), RENUMBER));
assert!(!numbered(&mut doc, Request::default(), RENUMBER), "the same numbers again change nothing");
let bom = listed(&doc, Request::default()).unwrap();
assert!(bom.numbered);
assert_eq!(items(&bom), [item("1", "A", ""), item("2", "B", ""), item("3", "C", "")]);
// A new part, first by part number: the highest number + 1, listed by it, saved by the next change.
let (d, _) = add(&mut doc, "D", 0);
number(&mut doc, d, "P0");
assert_eq!(items(&listed(&doc, Request::default()).unwrap()).last(), Some(&item("4", "D", "p")));
assert!(recorded(&mut doc));
assert!(!recorded(&mut doc), "saved once");
assert_eq!(items(&listed(&doc, Request::default()).unwrap()).last(), Some(&item("4", "D", "")));
// B removed leaves a gap, and back it has its number again.
doc.occurrences.retain(|o| o.id != ob);
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A", ""), item("3", "C", ""), item("4", "D", "")]);
assert!(!recorded(&mut doc), "a gap isn't filled");
// Setting another row's number keeps B's: it isn't listed, but it is saved.
assert!(numbered(&mut doc, Request::default(), Numbering::Set { row: vec![1], item: None, locked: Some(true) }));
assert!(numbered(&mut doc, Request::default(), Numbering::Set { row: vec![1], item: None, locked: Some(false) }));
let (e, _) = add(&mut doc, "E", 0);
assert!(recorded(&mut doc));
place(&mut doc, b, 0);
let bom = listed(&doc, Request::default()).unwrap();
assert_eq!(items(&bom), [item("1", "A", ""), item("2", "B", ""), item("3", "C", ""), item("4", "D", ""), item("5", "E", "")]);
assert_eq!(bom.rows.last().map(|r| r.key.member.component), Some(e));
// A renumber closing the gaps in item order, or in the list's order (by part number).
doc.occurrences.retain(|o| o.component != b);
assert!(numbered(&mut doc, Request::default(), Numbering::Renumber { start: 1, step: 1, order: Order::Item }));
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A", ""), item("2", "C", ""), item("3", "D", ""), item("4", "E", "")]);
assert!(numbered(&mut doc, Request::default(), Numbering::Renumber { start: 10, step: 5, order: Order::List }));
assert_eq!(
items(&listed(&doc, Request::default()).unwrap()),
[item("10", "D", ""), item("15", "A", ""), item("20", "C", ""), item("25", "E", "")]
);
// The renumber forgot B: back, it is new.
place(&mut doc, b, 0);
assert_eq!(items(&listed(&doc, Request::default()).unwrap()).last(), Some(&item("26", "B", "p")));
}
/// A view numbered twice, which only a damaged file has: listing reads the first numbering, and
/// recording drops the second, so a change never lists the view again for it.
#[test]
fn a_view_numbered_twice_keeps_its_first_numbering() {
let (mut doc, _) = three_parts();
assert!(numbered(&mut doc, Request::default(), RENUMBER));
let mut second = doc.assembly.item_numbers[0].clone();
second.items.iter_mut().for_each(|i| i.number += 10);
doc.assembly.item_numbers.push(second);
let first = [item("1", "A", ""), item("2", "B", ""), item("3", "C", "")];
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), first);
assert!(recorded(&mut doc));
assert_eq!(doc.assembly.item_numbers.len(), 1);
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), first);
assert!(!recorded(&mut doc));
}
#[test]
fn a_row_keeps_its_number_through_a_part_number_edit() {
let (mut doc, [(a, _), _, _]) = three_parts();
assert!(numbered(&mut doc, Request::default(), RENUMBER));
number(&mut doc, a, "P9");
assert!(!recorded(&mut doc), "no new row");
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A", ""), item("2", "B", ""), item("3", "C", "")]);
}
/// The defect the plan names: a renumber with locked rows never gives two rows one number.
#[test]
fn a_renumber_keeps_locked_numbers_and_never_gives_two_rows_one() {
let (mut doc, _) = three_parts();
let (d, _) = add(&mut doc, "D", 0);
number(&mut doc, d, "P4");
// Setting a number numbers the view first; one another row has is marked on both.
assert!(numbered(&mut doc, Request::default(), Numbering::Set { row: vec![3], item: Some(2), locked: Some(true) }));
assert_eq!(
items(&listed(&doc, Request::default()).unwrap()),
[item("1", "A", ""), item("2", "B", "d"), item("2", "C", "ld"), item("4", "D", "")]
);
for (start, step, want) in [(1, 1, ["1", "3", "2", "4"]), (2, 1, ["3", "4", "2", "5"]), (1, 2, ["1", "3", "2", "5"])] {
assert!(numbered(&mut doc, Request::default(), Numbering::Renumber { start, step, order: Order::List }));
let bom = listed(&doc, Request::default()).unwrap();
let by_name: BTreeMap<String, (String, String)> = items(&bom).into_iter().map(|(i, n, f)| (n, (i, f))).collect();
let got: Vec<&str> = ["A", "B", "C", "D"].iter().map(|n| by_name[*n].0.as_str()).collect();
assert_eq!(got, want, "from {start} by {step}");
assert!(bom.rows.iter().all(|r| !r.duplicate), "from {start} by {step}");
assert_eq!(by_name["C"].1, "l");
}
// Unlocked, the next renumber moves it.
assert!(numbered(&mut doc, Request::default(), Numbering::Set { row: vec![2], item: None, locked: Some(false) }));
assert!(numbered(&mut doc, Request::default(), RENUMBER));
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A", ""), item("2", "B", ""), item("3", "C", ""), item("4", "D", "")]);
}
#[test]
fn all_levels_are_numbered_within_each_parent() {
let mut doc = Document::default();
let (frame, _) = add(&mut doc, "Frame", 0);
let (bolt, _) = add(&mut doc, "Bolt", 0);
number(&mut doc, frame, "F");
number(&mut doc, bolt, "B");
let (beam, _) = add(&mut doc, "Beam", frame);
number(&mut doc, beam, "F1");
let all = view(View::AllLevels);
assert!(numbered(&mut doc, all, RENUMBER));
assert_eq!(items(&listed(&doc, all).unwrap()), [item("1", "Bolt", ""), item("2", "Frame", ""), item("2.1", "Beam", "")]);
// A plate in the frame is the frame's next; the design's parts-only view isn't numbered.
let (plate, _) = add(&mut doc, "Plate", frame);
number(&mut doc, plate, "F0");
assert_eq!(items(&listed(&doc, all).unwrap()).last(), Some(&item("2.2", "Plate", "p")));
assert!(recorded(&mut doc));
assert_eq!(items(&listed(&doc, all).unwrap()).last(), Some(&item("2.2", "Plate", "")));
assert!(!listed(&doc, Request::default()).unwrap().numbered);
// Renumbered in the list's order, each level from the start.
assert!(numbered(&mut doc, all, Numbering::Renumber { start: 5, step: 5, order: Order::List }));
assert_eq!(
items(&listed(&doc, all).unwrap()),
[item("5", "Bolt", ""), item("10", "Frame", ""), item("10.5", "Plate", ""), item("10.10", "Beam", "")]
);
// A component's own list is numbered apart from the design's.
let own = Request { source: frame, ..all };
assert!(!listed(&doc, own).unwrap().numbered);
assert!(numbered(&mut doc, own, RENUMBER));
assert_eq!(items(&listed(&doc, own).unwrap()), [item("1", "Plate", ""), item("2", "Beam", "")]);
assert_eq!(items(&listed(&doc, all).unwrap())[2], item("10.5", "Plate", ""));
}
/// A numbering keeps the list options it was made with: the next change saves the rows they list.
/// A list with other options reads the same numbers by row, the rest provisional.
#[test]
fn a_numbering_keeps_its_list_options_and_other_lists_read_its_numbers() {
let (mut doc, [_, (b, _), _]) = three_parts();
number(&mut doc, b, "P1");
let by_definition = Request { group: Group::Definition, ..Request::default() };
assert!(numbered(&mut doc, Request::default(), RENUMBER));
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("1", "A; B", ""), item("2", "C", "")]);
assert_eq!(items(&listed(&doc, by_definition).unwrap()), [item("1", "A", ""), item("2", "C", ""), item("3", "B", "p")]);
assert!(!recorded(&mut doc), "the numbering lists by part number: nothing new");
assert!(numbered(&mut doc, by_definition, Numbering::Set { row: vec![3], item: Some(7), locked: None }));
let saved = &doc.assembly.item_numbers;
assert_eq!((saved.len(), saved[0].group), (1, Group::Definition), "one numbering a view, with the options last used");
assert_eq!(items(&listed(&doc, by_definition).unwrap()), [item("1", "A", ""), item("2", "C", ""), item("7", "B", "")]);
}
#[test]
fn numbers_are_refused_outside_their_range_and_rows_are_found_as_shown() {
let (mut doc, _) = three_parts();
let refused = |doc: &mut Document, how: Numbering| {
let state = ModelState::default();
super::number(&mut Tree::new(doc, &state), &Request::default(), how).unwrap_err().to_string()
};
for (start, step) in [(0, 1), (1, 0), (MAX_ITEM + 1, 1)] {
assert!(refused(&mut doc, Numbering::Renumber { start, step, order: Order::List }).contains("1 to 999999"), "{start} by {step}");
}
assert!(refused(&mut doc, Numbering::Renumber { start: MAX_ITEM - 1, step: 1, order: Order::List }).contains("passes 999999"));
assert!(refused(&mut doc, Numbering::Renumber { start: 1, step: MAX_ITEM, order: Order::List }).contains("passes 999999"));
assert!(refused(&mut doc, Numbering::Set { row: vec![4], item: Some(1), locked: None }).contains("item 4"));
assert!(refused(&mut doc, Numbering::Set { row: vec![1, 1], item: Some(1), locked: None }).contains("item 1.1"));
assert!(refused(&mut doc, Numbering::Set { row: vec![1], item: Some(0), locked: None }).contains("1 to 999999"));
assert!(doc.assembly.item_numbers.is_empty(), "nothing saved");
// The highest number is taken; a renumber that would pass it is refused whole.
assert!(numbered(&mut doc, Request::default(), Numbering::Renumber { start: MAX_ITEM - 2, step: 1, order: Order::List }));
assert_eq!(listed(&doc, Request::default()).unwrap().rows.last().map(|r| r.item), Some(MAX_ITEM));
}
/// A damaged file's numbers: one out of range is no number, and of a key saved twice the first
/// holds. A component's numbering goes with it.
#[test]
fn saved_numbers_are_read_leniently_and_go_with_their_component() {
let (mut doc, [(a, _), (b, _), (c, _)]) = three_parts();
let key = |component| RowKey { parents: vec![], member: Member { component, bom_type: Normal } };
let saved = |key, number| ItemNumber { key, number, locked: false };
doc.assembly.item_numbers.push(ItemNumbers {
source: 0,
view: View::PartsOnly,
group: Group::PartNumber,
fold: false,
purchased_contents: false,
items: vec![saved(key(a), 4), saved(key(a), 9), saved(key(b), 0), saved(key(c), MAX_ITEM + 1)],
});
assert_eq!(items(&listed(&doc, Request::default()).unwrap()), [item("4", "A", ""), item("5", "B", "p"), item("6", "C", "p")]);
let (sub, _) = add(&mut doc, "Sub", 0);
add(&mut doc, "Inner", sub);
assert!(numbered(&mut doc, Request { source: sub, ..Request::default() }, RENUMBER));
assert_eq!(doc.assembly.item_numbers.len(), 2);
doc.components.retain(|c| c.id != sub);
doc.occurrences.retain(|o| o.component != sub && o.parent != sub);
assert!(recorded(&mut doc));
assert_eq!(doc.assembly.item_numbers.iter().map(|n| n.source).collect::<Vec<_>>(), [0], "the design's is kept");
}
/// Numbers are saved in the design under `assembly.item_numbers`, and change no geometry.
#[test]
fn item_numbers_are_saved_and_are_part_properties() {
let (mut doc, _) = three_parts();
let before = doc.clone();
assert!(numbered(&mut doc, Request { fold: true, ..view(View::AllLevels) }, RENUMBER));
assert!(doc.differs_only_in_part_properties(&before));
assert!(!doc.differs_only_in_part_properties(&doc.clone()), "no change is no part-properties change");
let json = serde_json::to_value(&doc.assembly).unwrap();
let first = &json["item_numbers"][0];
assert_eq!(
(&first["source"], &first["levels"], &first["group_by"], &first["group_children"]),
(&json!(0), &json!("all"), &json!("part_number"), &json!(true))
);
assert_eq!(first["items"][0], json!({"member": {"component": doc.components[0].id, "bom_type": "normal"}, "number": 1}));
// Through the whole design, as a save writes it: a design whose assembly holds only numbers keeps them.
assert_eq!(Document::from_json(&doc.to_json()).unwrap().assembly, doc.assembly);
assert!(serde_json::to_value(&before.assembly).unwrap().get("item_numbers").is_none(), "a design never numbered saves nothing new");
}
#[test]
fn a_quantity_is_pieces_or_a_length_with_the_designs_parameters() {
let mut doc = Document::default();
doc.set_param("span", "100", Some("mm"), None).unwrap();
doc.set_param("packs", "3", Some(""), None).unwrap();
let amount = |text: &str| super::amount(&doc, text);
assert_eq!(amount("4").unwrap(), Amount::Pieces(4.0));
assert_eq!(amount("packs * 4").unwrap(), Amount::Pieces(12.0));
assert_eq!(amount("2.5 m").unwrap(), Amount::Length(2500.0));
assert_eq!(amount("10 in").unwrap(), Amount::Length(254.0));
assert_eq!(amount("2 * span + 50").unwrap(), Amount::Length(250.0), "a bare number beside a length is mm");
let refused = |text: &str| amount(text).unwrap_err().to_string();
assert!(refused("90 deg").contains("a number of pieces or a length"), "{}", refused("90 deg"));
assert!(refused("span * span").contains("not supported yet: area"));
for mass_or_volume in ["span^3", "50 g", "0.5 kg", "2 lb", "500 ml"] {
assert!(refused(mass_or_volume).contains("not supported yet: mass and volume"), "{mass_or_volume}: {}", refused(mass_or_volume));
}
for out_of_range in ["0", "-1", "-2 m", "1e9 + 1", "1001 km"] {
assert!(refused(out_of_range).contains("above 0"), "{out_of_range}: {}", refused(out_of_range));
}
assert_eq!(amount("1e9").unwrap(), Amount::Pieces(1e9), "the cap itself");
assert!(refused("cable_length").contains("unknown parameter `cable_length`"));
assert!(amount("").is_err() && amount("2 *").is_err());
}
#[test]
fn a_rows_quantity_adds_up_its_placements() {
let mut doc = Document::default();
doc.set_param("cable_length", "2.5", Some("m"), None).unwrap();
let (cable, _) = add(&mut doc, "Cable", 0);
set(&mut doc, cable, "bom_quantity", "cable_length");
place(&mut doc, cable, 0);
place(&mut doc, cable, 0);
let (pack, _) = add(&mut doc, "Pack", 0);
set(&mut doc, pack, "bom_quantity", "4");
place(&mut doc, pack, 0);
add(&mut doc, "Plate", 0);
// Two definitions of one part number: one with a length, one a piece.
let (hose, _) = add(&mut doc, "Hose", 0);
let (hose_b, _) = add(&mut doc, "Hose B", 0);
number(&mut doc, hose, "H-1");
number(&mut doc, hose_b, "H-1");
set(&mut doc, hose, "bom_quantity", "500 mm");
// A subassembly placed twice holding the pack once.
let (kit, _) = add(&mut doc, "Kit", 0);
place(&mut doc, kit, 0);
place(&mut doc, pack, kit);
let quantities = |doc: &Document, request: Request| -> Vec<(String, Quantity)> {
listed(doc, request).unwrap().rows_deep().into_iter().map(|(_, r)| (r.names.join("; "), r.quantity)).collect()
};
let q = quantities(&doc, view(View::PartsOnly));
let of = |q: &[(String, Quantity)], name: &str| q.iter().find(|(n, _)| n == name).map(|(_, q)| *q);
assert_eq!(of(&q, "Cable"), Some(Quantity::Length(7500.0)), "{q:?}");
assert_eq!(of(&q, "Pack"), Some(Quantity::Pieces(16.0)), "two of its own and one in each kit: {q:?}");
assert_eq!(of(&q, "Plate"), Some(Quantity::Count));
assert_eq!(of(&q, "Hose; Hose B"), Some(Quantity::Unknown(Uncounted::Mixed)));
// The kit's row in the top level; its own pack listed under it in all levels.
let q = quantities(&doc, view(View::AllLevels));
assert_eq!(q.iter().filter(|(n, _)| n == "Pack").map(|(_, q)| *q).collect::<Vec<_>>(), [Quantity::Pieces(8.0), Quantity::Pieces(8.0)], "{q:?}");
// A placement without a quantity beside packs counts one piece: a box of 4 and a loose one.
let (boxed, _) = add(&mut doc, "Box", 0);
let (loose, _) = add(&mut doc, "Loose", 0);
number(&mut doc, boxed, "K-1");
number(&mut doc, loose, "K-1");
set(&mut doc, boxed, "bom_quantity", "4");
assert_eq!(of(&quantities(&doc, view(View::PartsOnly)), "Box; Loose"), Some(Quantity::Pieces(5.0)));
// A parameter deleted (a damaged file): that row is unknown, the list still works.
doc.params.retain(|p| p.name != "cable_length");
let q = quantities(&doc, view(View::PartsOnly));
assert_eq!(of(&q, "Cable"), Some(Quantity::Unknown(Uncounted::Invalid)));
assert_eq!(of(&q, "Pack"), Some(Quantity::Pieces(16.0)));
}
#[test]
fn a_virtual_component_holding_something_is_named() {
let mut doc = Document::default();
let (grease, _) = add(&mut doc, "Grease", 0);
let (frame, _) = add(&mut doc, "Frame", 0);
let (bolt, _) = add(&mut doc, "Bolt", frame);
assert!(doc.virtual_holding().is_empty());
set_virtual(&mut doc, grease);
assert!(doc.virtual_holding().is_empty(), "an empty virtual component holds nothing");
place(&mut doc, bolt, grease);
assert_eq!(doc.virtual_holding(), [grease]);
set_virtual(&mut doc, frame);
assert_eq!(doc.virtual_holding(), [grease, frame]);
}
fn set_virtual(doc: &mut Document, component: u64) {
doc.components.iter_mut().find(|c| c.id == component).unwrap().metadata.0.insert("virtual".into(), json!(true));
}
#[test]
fn a_quantity_uses_its_parameters_as_any_expression_does() {
let mut doc = Document::default();
doc.set_param("span", "100", Some("mm"), None).unwrap();
let (cable, _) = add(&mut doc, "Cable", 0);
set(&mut doc, cable, "bom_quantity", "2 * span");
assert_eq!(doc.param_users("span"), [crate::ParamUser::Quantity { component: cable, name: "Cable".into() }]);
assert!(doc.remove_param("span").unwrap_err().to_string().contains("component Cable (quantity)"));
assert_eq!(doc.rename_param("span", "width").unwrap(), 1);
assert_eq!(doc.components[0].metadata.bom_quantity(), Some("2 * width"));
assert_eq!(amount(&doc, "2 * width").unwrap(), Amount::Length(200.0));
}
/// A balloon's item reads the row its part is counted in, by a path from the top of the design: in
/// all levels with the rows it is under, in a component's list only below that component, and the
/// reasons there is none stay apart.
#[test]
fn item_of_reads_a_balloons_row() {
let (doc, at) = mixed();
let path = |names: &[&str]| -> Vec<u64> { names.iter().map(|n| at[*n]).collect() };
let state = ModelState::default();
let mut tree = Tree::new(&doc, &state);
let all = list_with(&mut tree, &Request { view: View::AllLevels, ..Request::default() }, true).unwrap();
let (washer, _) = all.row_of(&tree, &path(&["Sub", "Washer"])).unwrap();
assert_eq!(washer.len(), 2, "under its subassembly");
assert_eq!(all.item_of(&tree, &path(&["Sub", "Washer"])), Ok(item_text(&washer)));
assert!(all.item_of(&tree, &path(&["Sub", "Washer"])).unwrap().contains('.'));
let sub = doc.find_component("Sub").unwrap();
let of_sub = list_with(&mut tree, &Request { source: sub, ..Request::default() }, true).unwrap();
assert_eq!(of_sub.item_of(&tree, &path(&["Sub", "Washer"])), Ok("1".to_string()), "cut to start below Sub");
assert_eq!(of_sub.item_of(&tree, &path(&["Sub"])), Err(Unlisted::NoRow), "the assembly listed has no row");
assert_eq!(of_sub.item_of(&tree, &path(&["Bolt"])), Err(Unlisted::NotFound), "not through Sub");
assert_eq!(of_sub.item_of(&tree, &[]), Err(Unlisted::NotFound));
assert_eq!(all.item_of(&tree, &path(&["Fixture", "Pin"])), Err(Unlisted::Reference));
assert_eq!(all.item_of(&tree, &[999]), Err(Unlisted::NotFound));
assert_eq!(all.item_of(&tree, &vec![at["Bolt"]; 65]), Err(Unlisted::NotFound));
assert!(of_sub.item_of(&tree, &vec![at["Sub"]; 65]).is_err());
assert_eq!(all.item_of(&tree, &[]), Err(Unlisted::NoRow));
}
+1 -1
View File
@@ -185,7 +185,7 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
}
assert_eq!(legacy, current, "the migration preserves every prior field");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).is_err());
assert!(crate::format::read(&legacy.to_string()).unwrap().newer_file.is_some());
}
#[test]
+3 -6
View File
@@ -12,9 +12,6 @@ pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solidco
first.origin.is_finite() && first.x.is_finite() && first.y.is_finite() && planes.all(|plane| plane == Some(first))
}
/// The longest face or edge name a saved model reference keeps, in bytes.
pub const MAX_REFERENCE_NAME_BYTES: usize = 512;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFaceTarget {
@@ -41,7 +38,7 @@ impl ConfigFaceTarget {
if self.body.is_empty()
|| self.body.len() > 128
|| self.name.is_empty()
|| self.name.len() > MAX_REFERENCE_NAME_BYTES
|| self.name.len() > 512
|| self.origin_feature == 0
|| [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9)
{
@@ -56,7 +53,7 @@ pub fn validate_face_target_json(target: &Value) -> Result<()> {
if !(4..=5).contains(&fields.len())
|| fields.keys().any(|key| !["body", "origin_feature", "name", "point", "certificate"].contains(&key.as_str()))
|| fields.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > MAX_REFERENCE_NAME_BYTES)
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|| fields.get("origin_feature").and_then(Value::as_u64).is_none_or(|id| id == 0)
|| fields.get("point").and_then(Value::as_array).is_none_or(|point| {
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
@@ -94,7 +91,7 @@ pub fn validate_styles_json(value: &Value) -> Result<()> {
if !certificate.is_null()
&& (face.get("strict_name").and_then(Value::as_bool) != Some(true)
|| face.get("body").and_then(Value::as_str).is_none_or(|body| body.is_empty() || body.len() > 128)
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > MAX_REFERENCE_NAME_BYTES)
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > 512)
|| face.get("point").and_then(Value::as_array).is_none_or(|point| {
point.len() != 3 || point.iter().any(|value| value.as_f64().is_none_or(|value| !value.is_finite() || value.abs() > 1e9))
}))
+10 -25
View File
@@ -207,7 +207,7 @@ fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)
}
/// The most edge names a vertex reference keeps.
pub(crate) const MAX_VERTEX_EDGES: usize = 16;
const MAX_VERTEX_EDGES: usize = 16;
/// Where the edges named `names` meet: the end every one of them has, nearest `at` (two edges
/// between the same two vertices share both). `None` when an edge is gone or they share no end.
@@ -230,32 +230,17 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
})
})
.collect::<Option<_>>()?;
shared_ends(&ends).into_iter().min_by(|p, q| p.dist(at).total_cmp(&q.dist(at)))
}
/// How near two points of a body are the same point: a millionth of their distance from the
/// origin, and at least a millionth of a millimetre.
pub(crate) fn point_tolerance(p: Vec3) -> f64 {
1e-6 * (1.0 + p.len())
}
/// Where edges meet: the ends in `ends[0]` that every other list in `ends` has too (to
/// [`point_tolerance`]), each once. Empty when they share none.
pub(crate) fn shared_ends<E: AsRef<[Vec3]>>(ends: &[E]) -> Vec<Vec3> {
let tol = point_tolerance;
let Some((first, rest)) = ends.split_first() else { return Vec::new() };
let mut shared: Vec<Vec3> = Vec::new();
for p in first.as_ref().iter().copied() {
let everywhere = rest.iter().all(|e| e.as_ref().iter().any(|q| q.dist(p) <= tol(p)));
if p.is_finite() && everywhere && !shared.iter().any(|q| q.dist(p) <= tol(p)) {
shared.push(p);
}
}
shared
let (first, rest) = ends.split_first()?;
let tol = |p: Vec3| 1e-6 * (1.0 + p.len());
first
.iter()
.copied()
.filter(|p| p.is_finite() && rest.iter().all(|e| e.iter().any(|q| q.dist(*p) <= tol(*p))))
.min_by(|p, q| p.dist(at).total_cmp(&q.dist(at)))
}
/// A polyline that is straight: its line.
pub(crate) fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
let (a, b) = (*pts.first()?, *pts.last()?);
let d = (b - a).normalized()?;
let len = (b - a).len();
@@ -263,7 +248,7 @@ pub(crate) fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
}
/// A polyline on a circle: (centre, unit normal, radius).
pub(crate) fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
let n = pts.len();
if n < 4 {
return None;
+337 -43
View File
@@ -1,31 +1,30 @@
//! The existing length display policy, shared by windowed and headless consumers.
//!
//! Values are stored in millimetres and displayed in any length unit of the expression unit table
//! ([`UNITS`]): a design's five units and the others a drawing may give its measurements in. One
//! table for both, so every length unit a design or a drawing accepts shows its own numbers. Other
//! names retain the millimetre fallback. 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.
//! 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.
use serde::{Deserialize, Serialize};
use crate::expr::{Kind, UNITS};
use crate::{DocError, Result, expr};
/// Units per millimetre and the unit's name, for a length unit of [`UNITS`]. Other names use
/// millimetres.
/// Units per millimetre and the unit's name. Unknown names use millimetres.
pub fn unit_scale(units: &str) -> (f64, &'static str) {
match UNITS.iter().find(|(name, kind, _)| *name == units && *kind == Kind::Length) {
Some(&(name, _, mm)) => (1.0 / mm, name),
None => (1.0, "mm"),
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 (`in` or `inch`) retain a meaningful fourth digit without promoting floating-point
/// noise. `value` is already converted to the requested display unit, not a millimetre value.
/// 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 unit_scale(units).0 != unit_scale("in").0 {
if units != "in" {
return base;
}
let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5;
@@ -90,20 +89,45 @@ pub fn format_value(value: f64, kind: expr::Kind, unit: &str, decimals: u8, f: &
if unit_kind != kind {
return Err(DocError::Invalid("number display unit has a different kind".into()));
}
format_display(value / scale, scale, decimals, f)
format_display(value / scale, scale, decimals, f, None)
}
/// Format a unit-less number with the same validation and decimal rounding policy.
pub fn format_number(x: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
format_display(x, 1.0, decimals, f)
format_display(x, 1.0, decimals, f, None)
}
fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat) -> Result<Shown> {
/// Format an internal value with a rounding step in the selected display unit.
/// The step is a shortest round-trip decimal, finite, positive and at most 1e6.
/// Input decimal preparation precedes step rounding; requested decimal precision follows it.
/// Both rounding operations use `f.ties`. The original value is never changed.
/// The exact snapped display magnitude must be at most 1e12. An out-of-range snap
/// returns [`DocError::Invalid`] before final decimal-place rounding, even if that
/// final rounding would bring the displayed value back into range.
pub fn format_value_with_step(value: f64, kind: expr::Kind, unit: &str, decimals: u8, step: f64, f: &NumberFormat) -> Result<Shown> {
let (unit_kind, scale) = expr::unit_info(unit).ok_or_else(|| DocError::Invalid("unknown number display unit".into()))?;
if unit_kind != kind {
return Err(DocError::Invalid("number display unit has a different kind".into()));
}
format_display(value / scale, scale, decimals, f, Some(step))
}
/// Format a unit-less number with the step policy of [`format_value_with_step`].
/// Decimal precision can round a step multiple again; `Shown.value` always matches the text.
/// Out-of-range snapped magnitudes are rejected before final decimal-place rounding.
pub fn format_number_with_step(x: f64, decimals: u8, step: f64, f: &NumberFormat) -> Result<Shown> {
format_display(x, 1.0, decimals, f, Some(step))
}
fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat, step: Option<f64>) -> Result<Shown> {
// The unit table supplies a positive finite scale. Its decimal step must be at most
// 1e-9 internal units; the precision floor can make the effective tie window narrower.
let unit_resolution = (scale.log10() + 9.0).ceil().max(0.0) as usize;
let resolution = unit_resolution.max(usize::from(decimals) + 3);
let (rounded, value) = round_decimal(x, decimals, resolution, f.ties)?;
let (rounded, value) = match step {
Some(step) => round_decimal_step(x, step, decimals, resolution, f.ties)?,
None => round_decimal(x, decimals, resolution, f.ties)?,
};
let (integer, fraction) = rounded.split_once('.').unwrap_or((&rounded, ""));
let fraction = if f.trailing_zeros { fraction } else { fraction.trim_end_matches('0') };
let integer = if !f.leading_zero && integer == "0" && !fraction.is_empty() { "" } else { integer };
@@ -122,16 +146,25 @@ fn format_display(x: f64, scale: f64, decimals: u8, f: &NumberFormat) -> Result<
Ok(Shown { text, value })
}
/// Prepare a bounded magnitude, then round decimal digits, never a binary tie comparison.
fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
/// Validate and prepare the magnitude once, identically for both rounding paths.
fn prepare_decimal(x: f64, decimals: u8, resolution: usize) -> Result<String> {
if decimals > 8 {
return Err(DocError::Invalid("number precision must be at most 8 decimal places".into()));
}
if !x.is_finite() || x.abs() > 1e12 {
return Err(DocError::Invalid("display value must be finite with magnitude at most 1e12".into()));
}
let prepared = format!("{:.*}", resolution, x.abs());
let (integer, fraction) = prepared.split_once('.').unwrap_or((&prepared, ""));
Ok(format!("{:.*}", resolution, x.abs()))
}
/// Prepare a bounded magnitude, then round decimal digits, never a binary tie comparison.
fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
let prepared = prepare_decimal(x, decimals, resolution)?;
round_prepared(&prepared, x.is_sign_negative(), decimals, ties)
}
fn round_prepared(prepared: &str, negative: bool, decimals: u8, ties: Ties) -> Result<(String, f64)> {
let (integer, fraction) = prepared.split_once('.').unwrap_or((prepared, ""));
let places = usize::from(decimals);
let mut digits: Vec<u8> = integer.bytes().chain(fraction.bytes().take(places)).collect();
let discarded = fraction.as_bytes().get(places..).unwrap_or_default();
@@ -167,10 +200,81 @@ fn round_decimal(x: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<
}
let rounded = String::from_utf8(digits).map_err(|_| DocError::Invalid("invalid prepared digits".into()))?;
let magnitude = rounded.parse::<f64>().map_err(|_| DocError::Invalid("invalid rounded number".into()))?;
let negative = x.is_sign_negative() && magnitude != 0.0;
let negative = negative && magnitude != 0.0;
Ok((rounded, if negative { -magnitude } else { magnitude }))
}
/// Divide decimal digits by a small decimal coefficient, retaining the full quotient.
/// A shortest f64 coefficient has at most 17 significant digits. With the unit table's
/// resolution <= 15, a scaled divisor is <= 1e21, so remainder * 10 fits u128.
/// At most 324 appended zeros are needed, even for the smallest positive subnormal.
fn round_decimal_step(x: f64, step: f64, decimals: u8, resolution: usize, ties: Ties) -> Result<(String, f64)> {
if !step.is_finite() || step <= 0.0 || step > 1e6 {
return Err(DocError::Invalid("number rounding step must be finite, positive and at most 1e6".into()));
}
let prepared = prepare_decimal(x, decimals, resolution)?;
let scientific = format!("{step:e}");
let (mantissa, exponent) = scientific.split_once('e').ok_or_else(|| DocError::Invalid("invalid decimal step".into()))?;
let fraction = mantissa.split_once('.').map_or(0, |(_, f)| f.len());
let coefficient = mantissa.replace('.', "").parse::<u128>().map_err(|_| DocError::Invalid("invalid step coefficient".into()))?;
let exponent = exponent.parse::<i32>().map_err(|_| DocError::Invalid("invalid step exponent".into()))? - fraction as i32;
let shift = exponent + resolution as i32;
let divisor = if shift > 0 { 10_u128.checked_pow(shift as u32).and_then(|n| coefficient.checked_mul(n)) } else { Some(coefficient) }
.ok_or_else(|| DocError::Invalid("rounding step scale overflow".into()))?;
let mut remainder = 0_u128;
let mut quotient = Vec::new();
for digit in prepared.bytes().filter(|d| *d != b'.').chain(std::iter::repeat_n(b'0', (-shift).max(0) as usize)) {
remainder = remainder * 10 + u128::from(digit - b'0');
quotient.push((remainder / divisor) as u8);
remainder %= divisor;
}
let up = remainder * 2 > divisor
|| (remainder * 2 == divisor && (ties == Ties::HalfAwayFromZero || quotient.last().is_some_and(|d| !d.is_multiple_of(2))));
// Exact quotient * coefficient, with the rounding increment folded into the carry.
let mut carry = if up { coefficient } else { 0 };
let mut product = Vec::new();
for digit in quotient.iter().rev() {
let n = u128::from(*digit) * coefficient + carry;
product.push(b'0' + (n % 10) as u8);
carry = n / 10;
}
while carry > 0 {
product.push(b'0' + (carry % 10) as u8);
carry /= 10;
}
product.reverse();
let product = String::from_utf8(product).map_err(|_| DocError::Invalid("invalid step digits".into()))?;
let mut magnitude = product.trim_start_matches('0').to_string();
if magnitude.is_empty() {
magnitude.push('0');
}
if exponent >= 0 {
if magnitude != "0" {
magnitude.extend(std::iter::repeat_n('0', exponent as usize));
}
} else {
let places = (-exponent) as usize;
if magnitude.len() <= places {
magnitude = format!("{}{}", "0".repeat(places + 1 - magnitude.len()), magnitude);
}
magnitude.insert(magnitude.len() - places, '.');
}
let (integer, fraction) = magnitude.split_once('.').unwrap_or((&magnitude, ""));
let whole = integer.parse::<u64>().map_err(|_| DocError::Invalid("rounded display magnitude overflow".into()))?;
if whole > 1_000_000_000_000 || (whole == 1_000_000_000_000 && fraction.bytes().any(|d| d != b'0')) {
return Err(DocError::Invalid("rounded display magnitude must be at most 1e12".into()));
}
// round_prepared needs at least the requested fraction digits, including for integer steps.
let missing = usize::from(decimals).saturating_sub(fraction.len());
if missing > 0 {
if !magnitude.contains('.') {
magnitude.push('.');
}
magnitude.extend(std::iter::repeat_n('0', missing));
}
round_prepared(&magnitude, x.is_sign_negative(), decimals, ties)
}
fn grouped_digits(text: &mut String, digits: &str, integer: bool, groups: bool) {
for (i, digit) in digits.chars().enumerate() {
if groups && digits.len() >= 5 && i > 0 && if integer { (digits.len() - i).is_multiple_of(3) } else { i.is_multiple_of(3) } {
@@ -183,7 +287,6 @@ fn grouped_digits(text: &mut String, digits: &str, integer: bool, groups: bool)
#[cfg(test)]
mod tests {
use super::{format_length, length_number, length_precision, unit_scale};
use crate::expr::{Kind, UNITS};
#[test]
fn all_design_units_keep_bare_and_suffixed_labels() {
@@ -247,26 +350,9 @@ mod tests {
assert_eq!(format_length(-0.0, "in", 2), "-0.000 in");
}
#[test]
fn every_length_unit_shows_its_own_numbers() {
// Each length unit of the expression table, which a design's units and a drawing's unit
// come from, never millimetres unless it is millimetres.
for &(name, kind, mm) in UNITS {
if kind != Kind::Length {
continue;
}
assert_eq!(unit_scale(name), (1.0 / mm, name), "{name}");
let bare = if matches!(name, "in" | "inch") { "12.500" } else { "12.50" };
assert_eq!(length_number(12.5 * mm, name, 2), bare, "{name}");
assert_eq!(format_length(12.5 * mm, name, 2), format!("{bare} {name}"), "{name}");
}
assert_eq!(length_number(25.4 * 1.2345, "inch", 0), "1.2345");
}
#[test]
fn unknown_display_units_keep_the_millimetre_fallback() {
// Names that aren't a length unit of the expression table, angles included.
for units in ["", " in ", "IN", "unsupported", "deg", "rad"] {
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}");
@@ -490,6 +576,214 @@ mod number_tests {
}
}
#[test]
fn step_ties_and_decimal_fractions() {
for (x, step, dp, even, away) in [
(1.25, 0.5, 1, "1.0", "1.5"),
(1.75, 0.5, 1, "2.0", "2.0"),
(12.5, 5.0, 0, "10", "15"),
(25.0, 10.0, 0, "20", "30"),
(0.125, 0.05, 2, "0.10", "0.15"),
(0.375, 0.25, 2, "0.50", "0.50"),
(0.15, 0.1, 1, "0.2", "0.2"),
(0.25, 0.1, 1, "0.2", "0.3"),
(1.25, 0.5, 0, "1", "2"),
] {
for (ties, expected) in [(Ties::HalfEven, even), (Ties::HalfAwayFromZero, away)] {
let f = NumberFormat { ties, ..NumberFormat::default() };
for sign in [1.0, -1.0] {
let shown = format_number_with_step(sign * x, dp, step, &f).unwrap();
assert_eq!(shown.text, if sign < 0.0 { format!("−{expected}") } else { expected.into() });
assert_eq!(shown.value, sign * expected.parse::<f64>().unwrap());
}
}
}
}
#[test]
fn step_prepares_display_before_division_and_keeps_boundary_neighbors() {
for ties in [Ties::HalfEven, Ties::HalfAwayFromZero] {
let f = NumberFormat { ties, ..NumberFormat::default() };
for x in [1.75_f64.next_down(), 1.75, 1.75_f64.next_up(), 1.7499999996] {
assert_eq!(format_number_with_step(x, 1, 0.5, &f).unwrap().text, "2.0");
}
for x in [1.25_f64.next_down(), 1.25, 1.25_f64.next_up()] {
let expected = if ties == Ties::HalfEven { "1.0" } else { "1.5" };
assert_eq!(format_number_with_step(x, 1, 0.5, &f).unwrap().text, expected);
assert_eq!(format_number_with_step(-x, 1, 0.5, &f).unwrap().text, format!("−{expected}"));
}
assert_eq!(format_number_with_step(1.7499999994, 1, 0.5, &f).unwrap().text, "1.5");
assert_eq!(format_number_with_step(1.7500000006, 1, 0.5, &f).unwrap().text, "2.0");
}
let f = NumberFormat::default();
// The precision floor narrows the preparation window at eight decimals.
assert_eq!(format_number_with_step(1.7499999996, 8, 0.5, &f).unwrap().text, "1.50000000");
}
#[test]
fn step_tiny_values_large_magnitudes_and_exact_final_digits() {
let f = NumberFormat::default();
for step in [f64::from_bits(1), f64::MIN_POSITIVE, 1e-200, 1e-20, 1e-8] {
for x in [0.0, -0.0, f64::from_bits(1), -1e11, 1e11, 34947628153.461784] {
let shown = format_number_with_step(x, 5, step, &f).unwrap();
assert!(shown.value.is_finite());
assert_eq!(shown.value, shown.text.replace('−', "-").parse::<f64>().unwrap());
}
}
assert_eq!(format_number_with_step(1e12, 8, f64::from_bits(1), &f).unwrap().text, "1000000000000.00000000");
assert_eq!(format_number_with_step(34947628153.461784, 5, 1e-8, &f).unwrap().text, "34947628153.46178");
// A tiny step can move an exact decimal tie by less than any f64 ULP.
// Keep those digits through the second rounding rather than parsing the snap to f64.
assert_eq!(format_number_with_step(1.25, 1, 3e-323, &f).unwrap().text, "1.3");
assert_eq!(format_number_with_step(-1.25, 1, 3e-323, &f).unwrap().text, "−1.3");
for step in [0.5, 10.0, 1e6] {
for x in [-0.0, -0.1] {
let shown = format_number_with_step(x, 2, step, &f).unwrap();
assert_eq!(shown.text, "0.00");
assert_eq!(shown.value.to_bits(), 0.0_f64.to_bits());
}
}
}
#[test]
fn step_extreme_coefficients_have_independent_expected_digits() {
// Shortest decimals: largest subnormal = 2225073858507201 * 10^-323,
// smallest normal = 22250738585072014 * 10^-324. For x=1.25, integer
// division leaves remainders 2221948386734081 and 354296156594724.
// Twice the first is above its divisor, twice the second below: the
// snap is just above/below 1.25, so final rounding gives 1.3/1.2 in BOTH modes.
// Other expected digits were calculated with independent rational arithmetic.
for (step, quarter) in [(f64::MIN_POSITIVE.next_down(), "1.3"), (f64::MIN_POSITIVE, "1.2")] {
for ties in [Ties::HalfEven, Ties::HalfAwayFromZero] {
let f = NumberFormat { ties, ..NumberFormat::default() };
for (x, dp, expected) in [
(1.25, 1, quarter),
(99999999999.75, 0, "100000000000"),
(99999999999.75, 1, "99999999999.7"),
(99999999999.75, 2, "99999999999.75"),
(34947628153.461784, 2, "34947628153.46"),
(34947628153.461784, 5, "34947628153.46178"),
] {
for sign in [1.0, -1.0] {
let shown = format_number_with_step(sign * x, dp, step, &f).unwrap();
let text = if sign < 0.0 { format!("−{expected}") } else { expected.into() };
assert_eq!(shown.text, text, "x={x:?}, step={step:e}, dp={dp}, ties={ties:?}");
assert_eq!(shown.value, sign * expected.parse::<f64>().unwrap());
}
}
}
}
}
#[test]
fn step_and_final_decimal_rounding_carry_independently() {
// 9.975 / .05 = 199.5: both modes increment the step index to 200.
// 9.96 / .03 = 332 exactly: only the final rounding carries to 10.0.
// 1.26 / .1 = 12.6: the snap is 1.3, then zero decimals give 1 (not 1.26).
// 1.49 / .5 = 2.98: the snap is 1.5, then zero decimals give 2 (not 1).
for (x, step, dp, even, away) in [
(9.975, 0.05, 2, "10.00", "10.00"),
(9.96, 0.03, 1, "10.0", "10.0"),
(1.26, 0.1, 0, "1", "1"),
(1.49, 0.5, 0, "2", "2"),
(100000000000.25, 0.1, 0, "100000000000", "100000000000"),
(100000000000.25, 0.1, 1, "100000000000.2", "100000000000.3"),
(100000000000.25, 0.1, 2, "100000000000.20", "100000000000.30"),
(999999999999.75, 0.25, 0, "1000000000000", "1000000000000"),
] {
for (ties, expected) in [(Ties::HalfEven, even), (Ties::HalfAwayFromZero, away)] {
let f = NumberFormat { ties, ..NumberFormat::default() };
for sign in [1.0, -1.0] {
let shown = format_number_with_step(sign * x, dp, step, &f).unwrap();
assert_eq!(shown.text, if sign < 0.0 { format!("−{expected}") } else { expected.into() });
assert_eq!(shown.value, sign * expected.parse::<f64>().unwrap());
}
}
}
}
#[test]
fn step_rejects_invalid_inputs_and_out_of_range_snap() {
let f = NumberFormat::default();
for step in [0.0, -0.0, -0.5, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e6_f64.next_up()] {
assert!(matches!(format_number_with_step(1.0, 2, step, &f), Err(DocError::Invalid(_))));
assert!(matches!(format_value_with_step(1.0, Length, "mm", 2, step, &f), Err(DocError::Invalid(_))));
}
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e12_f64.next_up(), -1e12_f64.next_up()] {
assert!(matches!(format_number_with_step(x, 2, 0.5, &f), Err(DocError::Invalid(_))));
}
for dp in [9, 255] {
assert!(matches!(format_number_with_step(1.0, dp, 0.5, &f), Err(DocError::Invalid(_))));
}
for x in [1e12, -1e12] {
for ties in [Ties::HalfEven, Ties::HalfAwayFromZero] {
let f = NumberFormat { ties, ..f };
for step in [600_000.0, 0.6] {
// The .6 snap has magnitude 1e12 + .2: reject it even though
// zero decimal places would otherwise bring it back to 1e12.
assert!(matches!(format_number_with_step(x, 0, step, &f), Err(DocError::Invalid(_))));
}
}
assert_eq!(format_number_with_step(x, 0, 1e6, &f).unwrap().value, x);
}
for (kind, unit) in [(Length, "deg"), (Angle, "mm"), (Length, "unknown")] {
assert!(matches!(format_value_with_step(1.0, kind, unit, 2, 0.5, &f), Err(DocError::Invalid(_))));
}
}
#[test]
fn step_units_options_and_input_preservation() {
let f = NumberFormat::default();
let input: f64 = 25.4 * 1.25;
let bits = input.to_bits();
assert_eq!(format_value_with_step(input, Length, "in", 1, 0.5, &f).unwrap().text, "1.0");
assert_eq!(input.to_bits(), bits);
assert_eq!(format_value_with_step(std::f64::consts::PI / 6.0, Angle, "deg", 1, 5.0, &f).unwrap().text, "30.0");
assert_eq!(format_value_with_step(1e15, Length, "m", 1, 10.0, &f).unwrap().value, 1e12);
let styled = NumberFormat { marker: DecimalMarker::Comma, groups: true, ..f };
assert_eq!(format_number_with_step(-12345.25, 2, 0.5, &styled).unwrap().text, "−12\u{202f}345,00");
let trim = NumberFormat { leading_zero: false, trailing_zeros: false, ..styled };
assert_eq!(format_number_with_step(-0.6, 3, 0.5, &trim).unwrap().text, "−,5");
assert_eq!(format_number_with_step(1.23456789, 8, 1e-8, &styled).unwrap().text, "1,234\u{202f}567\u{202f}89");
for (distance, expected) in [(0.4, "1.0"), (0.6, "0.5")] {
assert_eq!(format_value_with_step((0.75 - distance * 1e-4) * 1e-6, Length, "nm", 1, 0.5, &f).unwrap().text, expected);
}
for step in [1e6_f64.next_down(), 0.12345678901234567, 2.2250738585072014e-308] {
let shown = format_number_with_step(12345.6789, 8, step, &f).unwrap();
assert_eq!(shown.value, shown.text.parse::<f64>().unwrap());
}
// Explicitly keep both historical policies, including their different decimal ties.
assert_eq!(format_number(2.665, 2, &f).unwrap().text, "2.66");
assert_eq!(length_number(2.665, "mm", 2), "2.67");
}
#[test]
fn step_seeded_integer_reference_and_hostile_domain() {
let mut seed = 0x4896_1138_0abc_def1;
for _ in 0..20_000 {
// Independent bounded integer oracle: x and step are thousandths; dp=3.
let n = random(&mut seed) % 1_000_000;
let s = random(&mut seed) % 100_000 + 1;
for ties in [Ties::HalfEven, Ties::HalfAwayFromZero] {
let f = NumberFormat { ties, ..NumberFormat::default() };
let q = n / s;
let r = n % s;
let up = 2 * r > s || (2 * r == s && (ties == Ties::HalfAwayFromZero || q % 2 == 1));
let expected = (q + u64::from(up)) * s;
let text = format!("{}.{:03}", expected / 1000, expected % 1000);
let shown = format_number_with_step(n as f64 / 1000.0, 3, s as f64 / 1000.0, &f).unwrap();
assert_eq!(shown.text, text);
// Exercise arbitrary positive subnormals and normal steps without quotient overflow.
let step = f64::from_bits(random(&mut seed));
if step.is_finite() && step > 0.0 && step <= 1e6 {
let shown = format_number_with_step(n as f64, 3, step, &f).unwrap();
assert!(shown.value.is_finite() && shown.value >= 0.0);
assert_eq!(shown.value, shown.text.parse::<f64>().unwrap());
}
}
}
}
fn random(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
+56 -82
View File
@@ -504,7 +504,12 @@ impl EmbossFace {
/// Resolve this exact placement without expanding every assembly instance.
pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result<crate::Mat> {
doc.body_frame_at(body, &self.path)
let owner = doc.model_body_component(body);
let mut frame = Self::component_frame(doc, owner, &self.path)?;
if let Some(offset) = doc.model_body_offset(body) {
frame = crate::mat_mul(&frame, offset);
}
Ok(frame)
}
/// Resolve one saved component path without expanding other occurrences.
@@ -654,6 +659,12 @@ impl AlignFrame {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FeatureKind {
/// A feature a newer SolidCraft saved that this build can't read ([`crate::newer_file`]): it
/// holds its place on the timeline, reports an error when evaluated, and goes back into the
/// file as it came.
NewerFileFeature {
raw: crate::newer_file::RawFeature,
},
/// A recorded timeline ownership boundary. Geometry is converted from its
/// current local frame into the destination component frame exactly once.
BodyTransfer {
@@ -2405,6 +2416,12 @@ pub enum HoleKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(angle = "Tip angle")]
tip_angle: Option<Expr>,
/// Printing aid: two bridge layers this thick below the counterbore's floor (a slot along
/// X, then a square), so a printer bridges the step instead of sagging into it. Absent:
/// a plain counterbore.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Bridge layer")]
bridge: Option<Expr>,
},
Countersink {
#[walk(length = "Countersink diameter")]
@@ -2589,6 +2606,7 @@ impl FeatureKind {
/// Short type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "NewerFileFeature",
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "ExtrudeFeature",
@@ -2674,6 +2692,7 @@ impl FeatureKind {
/// Design-file variant key (e.g. `"extrude"`, `"box"`, `"sheet_flange"`).
pub fn kind_key(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "newer_file_feature",
FeatureKind::BodyTransfer { .. } => "body_transfer",
FeatureKind::Sketch { .. } => "sketch",
FeatureKind::Extrude { .. } => "extrude",
@@ -2836,6 +2855,7 @@ impl FeatureKind {
/// Default name prefix (`Extrude` → `Extrude1`).
pub fn base_name(&self) -> &'static str {
match self {
FeatureKind::NewerFileFeature { .. } => "Unknown",
FeatureKind::BodyTransfer { .. } => "BodyTransfer",
FeatureKind::Sketch { .. } => "Sketch",
FeatureKind::Extrude { .. } => "Extrude",
@@ -3108,6 +3128,10 @@ pub fn is_counted_body_name(name: &str) -> bool {
/// The document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
/// Set when a newer SolidCraft saved the file this came from: what to tell the user, and what
/// this build couldn't read and keeps for the next save ([`crate::newer_file`]).
#[serde(skip)]
pub newer_file: Option<std::sync::Arc<crate::newer_file::NewerFile>>,
/// Named image intent and immutable embedded HDR sources. Active jobs and device state are not saved.
#[serde(default, skip_serializing_if = "crate::render_settings::RenderSettings::is_empty")]
pub render: std::sync::Arc<crate::render_settings::RenderSettings>,
@@ -3218,12 +3242,6 @@ pub struct Document {
/// it saves.
#[serde(default, skip_serializing_if = "Visibility::is_empty")]
pub visibility: Visibility,
/// The design's drawings (#3890), by name, then id ([`crate::drawing::in_design`]), each behind
/// its own `Arc`, so an undo step shares every drawing it didn't change. Each is read by its own
/// family's rules ([`crate::format::read`]), never as a field of the design; a container keeps
/// each in its own entry.
#[serde(skip_deserializing, skip_serializing_if = "Vec::is_empty")]
pub drawings: Vec<std::sync::Arc<crate::drawing::Drawing>>,
}
/// The browser's view of a design: items and folders hidden or shown, sketch profiles and
@@ -3475,6 +3493,7 @@ impl Document {
pub fn new(name: &str) -> Self {
Document {
newer_file: None,
render: Default::default(),
format: default_format(),
format_pending: crate::format::pending_names(),
@@ -3511,7 +3530,6 @@ impl Document {
browser_order: Default::default(),
named_views: Vec::new(),
visibility: Visibility::default(),
drawings: Vec::new(),
}
}
@@ -3652,38 +3670,29 @@ impl Document {
*self == normalized
}
/// Only part properties changed (the fields `with_part_properties_of` names), and perhaps
/// drawings with them, as placing a parts list saves its numbers: nothing to evaluate. The
/// same whole-Document proof as [`Document::differs_only_in_render`].
/// Only part properties changed (the fields `with_part_properties_of` names): nothing to
/// evaluate. The same whole-Document proof as [`Document::differs_only_in_render`].
pub fn differs_only_in_part_properties(&self, previous: &Document) -> bool {
previous.with_part_properties_of(self).is_some_and(|normalized| normalized == *self)
}
/// This design with `other`'s part properties, or `None` when they are this design's
/// already (then nothing is copied). The part properties are the fields that change no
/// geometry: the design's metadata, each component's metadata and free properties, each
/// placement's bill of materials type and the saved item numbers. A field that joins them is
/// compared and copied here, and nowhere else.
/// geometry: the design's metadata, and each component's metadata and free properties. A
/// field that joins them is compared and copied here, and nowhere else.
fn with_part_properties_of(&self, other: &Document) -> Option<Document> {
let same = self.metadata == other.metadata
&& self.components.iter().zip(&other.components).all(|(c, o)| c.metadata == o.metadata && c.properties == o.properties)
&& self.occurrences.iter().zip(&other.occurrences).all(|(c, o)| c.bom_type == o.bom_type)
&& self.assembly.item_numbers == other.assembly.item_numbers;
&& self.components.iter().zip(&other.components).all(|(c, o)| c.metadata == o.metadata && c.properties == o.properties);
if same {
return None;
}
let mut out = self.clone();
out.metadata.clone_from(&other.metadata);
out.assembly.item_numbers.clone_from(&other.assembly.item_numbers);
out.drawings.clone_from(&other.drawings);
// Paired by position: if the components or placements themselves differ, so do the designs.
// Paired by position: if the components themselves differ, so do the designs.
for (c, o) in out.components.iter_mut().zip(&other.components) {
c.metadata.clone_from(&o.metadata);
c.properties.clone_from(&o.properties);
}
for (c, o) in out.occurrences.iter_mut().zip(&other.occurrences) {
c.bom_type = o.bom_type;
}
Some(out)
}
@@ -3699,32 +3708,6 @@ impl Document {
*self == normalized
}
/// Only drawings changed: nothing to evaluate, as a drawing reads the model and never changes
/// it. The same whole-Document proof as [`Document::differs_only_in_render`]; a model edit
/// shares every drawing's `Arc`, so it costs nothing here.
pub fn differs_only_in_drawings(&self, previous: &Document) -> bool {
let shared =
self.drawings.len() == previous.drawings.len() && self.drawings.iter().zip(&previous.drawings).all(|(a, b)| std::sync::Arc::ptr_eq(a, b));
if shared || self.drawings == previous.drawings {
return false;
}
let mut normalized = previous.clone();
normalized.drawings = self.drawings.clone();
*self == normalized
}
/// The design's drawing `id`.
pub fn drawing(&self, id: u64) -> Option<&std::sync::Arc<crate::drawing::Drawing>> {
self.drawings.iter().find(|d| d.id == id)
}
/// Put `drawing` in, in place of the one with its id if there is one, in the drawings' order.
pub fn put_drawing(&mut self, drawing: std::sync::Arc<crate::drawing::Drawing>) {
self.drawings.retain(|d| d.id != drawing.id);
self.drawings.push(drawing);
crate::drawing::sort(&mut self.drawings);
}
/// The colour a body is shown and exported in (sRGB as 0–1 fractions): its appearance,
/// else its component's, else its material's look, else the colour it was imported with.
/// (Faces may have their own: `face_looks`.)
@@ -3835,12 +3818,12 @@ impl Document {
}
pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 {
self.legacy_body_component_with(body, feature, |f| self.feature(f).map(|f| f.component))
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
}
/// Ownership in this evaluated timeline state, rather than a later transfer.
pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 {
self.model_body_component_with(body, |f| self.feature(f).map(|f| f.component))
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
}
/// [`Document::model_body_component`] of every body of `state`, in its order, with the
@@ -3865,25 +3848,17 @@ impl Document {
/// in one pass: the timeline is looked up once, not once per body.
pub fn body_owners(&self, state: &crate::ModelState) -> std::collections::HashSet<u64> {
let features: std::collections::HashMap<u64, u64> = self.features.iter().rev().map(|f| (f.id, f.component)).collect();
state.bodies.iter().map(|b| self.model_body_component_with(b, |f| features.get(&f).copied())).collect()
}
/// [`Document::model_body_component`]'s rule, written once: a body the evaluation transferred,
/// or placed from a source, keeps the component it was given; any other is
/// [`Document::legacy_body_component`]'s. `feature_component` finds a feature's component:
/// the timeline for one body, a map built once for many ([`Document::body_owners`]).
fn model_body_component_with(&self, body: &crate::ModelBody, feature_component: impl FnOnce(u64) -> Option<u64>) -> u64 {
if body.transferred || body.source_name.is_some() {
body.component
} else {
self.legacy_body_component_with(&body.name, body.feature, feature_component)
}
}
/// [`Document::legacy_body_component`]'s rule, written once: the component the body was moved
/// to, else its feature's, else the root.
fn legacy_body_component_with(&self, body: &str, feature: u64, feature_component: impl FnOnce(u64) -> Option<u64>) -> u64 {
self.body_components.get(body).copied().unwrap_or_else(|| feature_component(feature).unwrap_or(0))
state
.bodies
.iter()
.map(|b| {
if b.transferred || b.source_name.is_some() {
b.component
} else {
self.body_components.get(&b.name).copied().unwrap_or_else(|| features.get(&b.feature).copied().unwrap_or(0))
}
})
.collect()
}
/// Recorded transfers already converted geometry into the new local frame.
@@ -3891,17 +3866,6 @@ impl Document {
if body.transferred { None } else { self.body_offsets.get(&body.name) }
}
/// The frame `body`'s geometry is in, placed through the occurrence path `path` (empty: its
/// component's own placement): the placement, then the body's offset. One placement, without
/// expanding every assembly instance.
pub fn body_frame_at(&self, body: &crate::ModelBody, path: &[u64]) -> Result<crate::Mat> {
let mut frame = EmbossFace::component_frame(self, self.model_body_component(body), path)?;
if let Some(offset) = self.model_body_offset(body) {
frame = crate::mat_mul(&frame, offset);
}
Ok(frame)
}
/// Add a component under `parent` and return its id.
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
@@ -3951,7 +3915,12 @@ impl Document {
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
pub fn from_json(s: &str) -> Result<Document> {
crate::render_resources::check_json_budget(s)?;
// A newer build's resources may not read here; the section stays out then (`newer_file`).
if let Err(why) = crate::render_resources::check_json_budget(s)
&& !crate::format::says_newer(s)
{
return Err(why);
}
let mut d = crate::format::read(s)?;
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
return Err(DocError::Invalid("document too large".into()));
@@ -3963,6 +3932,11 @@ impl Document {
Ok(d)
}
pub fn to_json(&self) -> String {
if self.newer_file.is_some() {
let mut value = serde_json::to_value(self).unwrap_or_default();
crate::newer_file::restore(self, &mut value);
return serde_json::to_string_pretty(&value).unwrap_or_default();
}
serde_json::to_string_pretty(self).unwrap_or_default()
}
+173 -410
View File
@@ -1,33 +1,18 @@
//! A drawing (#3890): sheets of paper that draw a design. A drawing lives in the file of the design
//! it draws ([`Document::drawings`](crate::Document::drawings)): a design container keeps each in
//! its own entry, a plain JSON design inline. Each is a record of its own family,
//! `"format": "solidcraft-drawing/N"`, versioned by the same code as designs ([`DRAWING`]), so a
//! change to the record is a drawing change, not a design one.
//! A drawing (#3890): sheets of paper that draw designs saved in their own files. A drawing is a
//! file of its own, `"format": "solidcraft-drawing/N"`, versioned by the same code as designs
//! ([`DRAWING`]); format 1 is this record.
//!
//! - Each drawing has an **id**, unique among its design's drawings, and a design lists its
//! drawings by name, then id ([`in_design`]): nothing to merge when two people add drawings.
//! - **Sources** are the designs it draws. For now a drawing draws the design it is in: at most one
//! source, an id and nothing else. What the design was when the drawing last saw it is not saved
//! here.
//! - **Sheets**: the paper, a copy of its drawing border and of its title block with the text typed
//! over its cells' values, the resolution its pictures are rendered at, the component it
//! documents, and whether it is left out of the sheet count or of printing. The border and the
//! block are copies of what they were made with, never names, so a later built-in never changes a
//! saved drawing.
//! - **Sources** are the designs it draws: an id and the design file's path, relative to the
//! drawing's folder once the drawing is saved, else as given, with `/` between folders (a linked
//! design's [`DesignLink`](crate::DesignLink) rule). What a design was when the drawing last saw
//! it is not saved here.
//! - **Sheets**: the paper, a copy of its drawing border, the resolution its pictures are rendered
//! at, the component it documents, and whether it is left out of the sheet count or of printing.
//! The border is a copy of the profile it was made with, never a name, so a later built-in never
//! changes a saved drawing.
//! - **Units** follow each view's design or are one length unit for the whole drawing. The
//! **language** is the content's, a BCP 47 tag chosen when the drawing is made, whatever the
//! interface's is. **Properties** follow [`Metadata`]'s rules.
//! - The **font** its text is set in: a font family's name, never a file, so the drawing travels
//! (none: the built-in font; [`solidcraft_sketch::check_family`]).
//! - **Views** of its designs on its sheets, base and projected ([`view`]), and the **projection**
//! (first or third angle) every projected view follows.
//! - **Dimensions** on its views, each measuring named corners or edges of the view's model
//! ([`dimension`]).
//! - **Balloons** on its views, each pointing at a placed part by a named edge of it ([`balloon`]).
//! - **Images** on its sheets, each file kept once however many places it sits ([`image`]).
//! - **Tables** on its sheets: parts lists and revision tables ([`table`]).
//! - **Revisions**: one revision record for the whole drawing ([`RevisionRecord`]), its rows in the
//! order they were added, the newest last.
//!
//! The rules, which every later kind of record (views, annotations, tables) keeps too:
//! - Every object has an id of [`crate::ids`]: random, so objects added side by side never take
@@ -35,59 +20,35 @@
//! never gives a removed object's id again ([`Drawing::allocate_id`]).
//! - Every reference between records is an id and must resolve: a file with one that doesn't is
//! damaged and refused, never patched.
//! - 1 to [`MAX_SHEETS`] sheets; names of 1 to [`MAX_NAME_BYTES`] bytes.
//! - 1 to [`MAX_SHEETS`] sheets and at most [`MAX_SOURCES`] sources; names of 1 to
//! [`MAX_NAME_BYTES`] bytes; paths of 1 to [`MAX_PATH_BYTES`] bytes, without NUL.
//! - Nothing derived is saved, and a drawing always saves as the same bytes: fields in their
//! order, maps sorted, and lists of records in a fixed order (sheets as arranged; later
//! records by id), never the order they were added in. Records listed by id read in that order
//! whatever order a file lists them in: the order means nothing, so it is never damage.
//! order, maps sorted.
//! - A later change to the record is one file in `src/drawing/format/pending/` (its README).
//!
//! [`Drawing::from_json`] reads a drawing in this order: at most [`MAX_DRAWING_BYTES`] of text,
//! the family's format check, every number in range, the upgrade, the typed record with its views,
//! dimensions, balloons, images and notes by id, then [`Drawing::validate`] and each image's file
//! ([`Drawing::check_image_files`]); [`Drawing::from_value`] the same from the format check on.
//! [`Drawing::from_json`] reads a file in this order: at most [`MAX_DRAWING_BYTES`] of text, the
//! family's format check, every number in range, the upgrade, the typed record, then
//! [`Drawing::validate`].
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solidcore_record::Vec2;
use solidcraft_geom::Aabb2;
use solidcraft_geom::cell::Projection;
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
use solidcraft_geom::revision::RevisionRecord;
use solidcraft_geom::title_block::TitleBlock;
use crate::format::DRAWING;
use crate::ids::{self, IdSource};
use crate::{DocError, Metadata, Result, expr};
pub mod balloon;
pub mod by_id;
pub mod dimension;
pub mod fields;
pub mod image;
pub mod note;
pub mod table;
pub mod template;
pub mod view;
use balloon::Balloon;
use by_id::ById;
use dimension::Dimension;
use image::{ImageFile, SheetImage};
use note::{Anchor, Note};
use table::SheetTable;
use view::View;
/// The most drawings a design may hold.
pub const MAX_DRAWINGS: usize = 1000;
/// The most sheets a drawing may have.
pub const MAX_SHEETS: usize = 1000;
/// The most designs a drawing may draw.
pub const MAX_SOURCES: usize = 64;
/// The longest name of a drawing or a sheet, in bytes.
pub const MAX_NAME_BYTES: usize = 256;
/// The longest path to a design, in bytes.
pub const MAX_PATH_BYTES: usize = 4096;
/// The longest language tag: what RFC 5646 asks every reader to take (§4.4.1).
pub const MAX_LANGUAGE_CHARS: usize = 35;
/// The largest drawing read, as JSON text.
@@ -111,60 +72,88 @@ pub struct Drawing {
/// otherwise.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub format_pending: Vec<String>,
/// Unique among its design's drawings: a design container's entry for it is named by it.
#[serde(with = "crate::ids")]
pub id: u64,
pub name: String,
/// The designs the drawing draws: for now at most one, the design it is in.
/// The designs the drawing draws.
pub sources: Vec<Source>,
/// The drawing's properties and other tools' data, kept as they are, behind an `Arc` as the
/// sheets are, so an undo step shares them while they don't change.
#[serde(default, skip_serializing_if = "no_properties")]
pub properties: Arc<Metadata>,
/// The drawing's properties and other tools' data, kept as they are.
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub properties: Metadata,
#[serde(default)]
pub units: Units,
/// First or third angle: what every projected view shows, and the title block's symbol.
#[serde(default)]
pub projection: Projection,
/// The language of the drawing's content, a BCP 47 tag (`en-GB`).
pub language: String,
/// The font family its text is set in, by name; empty: the built-in font.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub font: String,
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
pub sheets: Vec<Arc<Sheet>>,
/// By id ([`Drawing::add_views`]), each behind its own `Arc`, as the sheets are.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub views: Vec<Arc<View>>,
/// By id ([`Drawing::add_dimensions`]), each behind its own `Arc`, as the views are.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dimensions: Vec<Arc<Dimension>>,
#[serde(default, skip_serializing_if = "ById::is_empty")]
pub balloons: ById<Balloon>,
/// The image files, once each however many places they sit.
#[serde(default, skip_serializing_if = "ById::is_empty")]
pub images: ById<ImageFile>,
/// Where they sit on the sheets.
#[serde(default, skip_serializing_if = "ById::is_empty")]
pub sheet_images: ById<SheetImage>,
/// The saved tables on its sheets: parts lists and revision tables.
#[serde(default, skip_serializing_if = "ById::is_empty")]
pub tables: ById<SheetTable>,
/// The notes on its sheets and in its views.
#[serde(default, skip_serializing_if = "ById::is_empty")]
pub notes: ById<Note>,
/// Behind an `Arc` as the sheets are, so an undo step shares it while it doesn't change.
#[serde(default, skip_serializing_if = "no_revisions")]
pub revisions: Arc<RevisionRecord>,
/// Set when a newer SolidCraft saved the file this came from.
#[serde(skip)]
pub newer_file: Option<Arc<NewerDrawing>>,
}
/// A design the drawing draws: the design the drawing is in. Views and sheets name it by its id,
/// so a drawing of other designs can add sources of another kind later.
/// The fields a drawing's record has: anything else in a file is a newer build's.
const RECORD_FIELDS: [&str; 7] = ["format", "name", "sources", "properties", "units", "language", "sheets"];
/// Move the elements of the list `key` that don't read as `T` out of a newer build's drawing.
fn take_unread<T: serde::de::DeserializeOwned>(top: &mut serde_json::Map<String, Value>, key: &str, one: &str, file: &mut NewerDrawing) {
let Some(Value::Array(list)) = top.get_mut(key) else { return };
let mut readable = Vec::with_capacity(list.len());
for element in std::mem::take(list) {
match DRAWING.check_numbers(&element, "properties").and_then(|()| serde_json::from_value::<T>(element.clone()).map_err(damaged)) {
Ok(_) => readable.push(element),
Err(why) => {
let name = element.get("name").or(element.get("path")).and_then(Value::as_str).unwrap_or("unnamed");
file.not_understood.push(format!("{one} `{name}` ({why})"));
file.elements.push((key.to_string(), element));
}
}
}
*list = readable;
}
/// What a drawing file says and holds that this build doesn't read ([`Drawing::from_newer`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NewerDrawing {
/// The number the file says.
pub format: u32,
/// The number this build reads up to.
pub reads_up_to: u32,
/// The changes the file names that this build doesn't have.
pub unknown_changes: Vec<String>,
/// What this build couldn't read, by name.
pub not_understood: Vec<String>,
fields: Vec<(String, Value)>,
elements: Vec<(String, Value)>,
}
impl NewerDrawing {
/// What to tell the user.
pub fn notice(&self) -> String {
format!(
"Saved by a newer SolidCraft (format {}, this version reads up to {}). If something's missing, update.",
self.format, self.reads_up_to
)
}
fn restore(&self, value: &mut Value) {
for (key, field) in &self.fields {
value[key] = field.clone();
}
for (key, element) in &self.elements {
if let Some(list) = value.get_mut(key).and_then(Value::as_array_mut) {
list.push(element.clone());
}
}
}
}
/// A design the drawing draws.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Source {
#[serde(with = "crate::ids")]
pub id: u64,
/// The design file: relative to the drawing's folder once the drawing is saved, else as given
/// (`/` separates folders).
pub path: String,
}
/// The length unit a drawing gives its measurements in.
@@ -189,14 +178,6 @@ pub struct Sheet {
pub sheet: drawing_sheet::Sheet,
/// A copy of the drawing border's profile (`None`: no drawing border).
pub border: Option<DrawingBorderProfile>,
/// A copy of its title block's definition (`None`: no title block), on the drawing border's
/// lower-right corner, so only a sheet with a border has one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title_block: Option<TitleBlock>,
/// Text shown in place of a title block cell's value, by the cell's name: the value is still
/// worked out, so it can be shown beside it.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub title_block_overrides: BTreeMap<String, String>,
/// The resolution its pictures (its shaded views) are rendered at, in dots per inch.
#[serde(default = "default_dpi")]
pub dpi: f64,
@@ -227,151 +208,105 @@ pub struct Excluded {
pub print: bool,
}
impl Sheet {
/// Where the sheet's drawing goes, in paper millimetres: inside its drawing border, or the
/// whole paper when it has none. Nothing on a sheet is saved against its border, so a change
/// of paper regrows the border and its zones by itself.
pub fn drawing_space(&self) -> Result<Aabb2> {
match &self.border {
Some(border) => Ok(border.layout(&self.sheet).map_err(from_sheet)?.drawing_border),
None => Ok(Aabb2 { min: Vec2::ZERO, max: self.sheet.size_mm().map_err(from_sheet)? }),
}
}
/// Where its title block sits, in paper millimetres (none without one), once its drawing border
/// and the block on it lay out on the paper: the rule a sheet is held to, as drawing them uses it.
pub fn title_block_outline(&self) -> Result<Option<Aabb2>> {
let border = self.border.as_ref().map(|border| border.layout(&self.sheet)).transpose().map_err(from_sheet)?;
let Some(block) = &self.title_block else { return Ok(None) };
let border = border.ok_or_else(|| DocError::Invalid("its title block sits on a drawing border, and it has none".into()))?;
Ok(Some(block.layout(&border).map_err(|e| DocError::Invalid(e.to_string()))?.outline))
}
/// Gives the sheet `block` (`None`: no title block). Text typed over a cell stays while `block`
/// has a text cell of that name; answers the cells whose typed text is dropped.
pub fn set_title_block(&mut self, block: Option<TitleBlock>) -> Vec<String> {
let kept = |name: &String| fields::overridable(block.as_ref(), name);
let lost = self.title_block_overrides.keys().filter(|name| !kept(name)).cloned().collect();
self.title_block_overrides.retain(|name, _| kept(name));
self.title_block = block;
lost
}
}
/// What is on one of a drawing's sheets ([`Drawing::on_sheet`]), each kind by id.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OnSheet {
pub views: Vec<u64>,
/// The dimensions in its views.
pub dimensions: Vec<u64>,
/// The balloons in its views.
pub balloons: Vec<u64>,
/// The images placed on it.
pub images: Vec<u64>,
/// The revision tags on it.
pub tags: Vec<u64>,
/// The tables on it.
pub tables: Vec<u64>,
/// The notes on it and in its views.
pub notes: Vec<u64>,
}
impl OnSheet {
/// Nothing is on the sheet.
pub fn is_empty(&self) -> bool {
self.views.is_empty()
&& self.dimensions.is_empty()
&& self.balloons.is_empty()
&& self.images.is_empty()
&& self.tags.is_empty()
&& self.tables.is_empty()
&& self.notes.is_empty()
}
}
fn default_dpi() -> f64 {
DEFAULT_DPI
}
impl Drawing {
/// A drawing of no designs yet, named `name` and written in `language`, with one sheet named
/// `sheet`: the paper `paper` with a copy of `border`. Its id, and then its sheet's, come from
/// `id_source`, the drawing's differing from every one in `taken` (its design's drawings').
/// `sheet`: the paper `paper` with a copy of `border`, its id from `id_source`.
pub fn new(
name: &str,
language: &str,
sheet: &str,
paper: drawing_sheet::Sheet,
border: Option<DrawingBorderProfile>,
taken: &BTreeSet<u64>,
id_source: &mut IdSource,
) -> Result<Drawing> {
let id = id_source.next(taken)?;
let sheet_id = id_source.next(&BTreeSet::from([id]))?;
let first = Sheet {
id: sheet_id,
name: sheet.into(),
sheet: paper,
border,
title_block: None,
title_block_overrides: BTreeMap::new(),
dpi: DEFAULT_DPI,
documents: None,
excluded: Excluded::default(),
};
let id = id_source.next(&BTreeSet::new())?;
let first = Sheet { id, name: sheet.into(), sheet: paper, border, dpi: DEFAULT_DPI, documents: None, excluded: Excluded::default() };
let drawing = Drawing {
format: DRAWING.format_string(DRAWING.current()),
format_pending: DRAWING.pending_names(),
id,
name: name.into(),
sources: Vec::new(),
properties: Arc::default(),
properties: Metadata::default(),
units: Units::default(),
projection: Projection::default(),
language: language.into(),
font: String::new(),
sheets: vec![Arc::new(first)],
views: Vec::new(),
dimensions: Vec::new(),
balloons: ById::default(),
images: ById::default(),
sheet_images: ById::default(),
tables: ById::default(),
notes: ById::default(),
revisions: Arc::default(),
newer_file: None,
};
drawing.validate()?;
Ok(drawing)
}
/// Read a drawing's JSON (any format this build knows; older ones are upgraded). A drawing
/// Read a drawing file's JSON (any format this build knows; older ones are upgraded). A file
/// that breaks a rule of the record is damaged and refused, never patched.
pub fn from_json(s: &str) -> Result<Drawing> {
if s.len() > MAX_DRAWING_BYTES {
return Err(DocError::Invalid(format!("drawing too large (at most {} MiB)", MAX_DRAWING_BYTES >> 20)));
}
let v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
Drawing::from_value(v)
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
let (version, applied) = DRAWING.take_version(&mut v)?;
if let Some(newer) = DRAWING.newer(version, applied.as_deref()) {
return Self::from_newer(v, version, applied, newer);
}
let d = Self::decode(v, version, applied.as_deref())?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
/// Read a drawing from its JSON value, as a plain JSON design holds it: [`Drawing::from_json`]
/// once the text is parsed.
pub fn from_value(mut v: Value) -> Result<Drawing> {
let (version, applied) = DRAWING.take_version(&mut v)?;
fn decode(mut v: Value, version: u32, applied: Option<&[String]>) -> Result<Drawing> {
DRAWING.check_numbers(&v, "properties")?;
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
let mut d: Drawing = serde_json::from_value(v).map_err(damaged)?;
let (views, dimensions) = (std::mem::take(&mut d.views), std::mem::take(&mut d.dimensions));
d.add_views(views);
d.add_dimensions(dimensions);
d.balloons.put_in_order();
d.images.put_in_order();
d.sheet_images.put_in_order();
d.tables.put_in_order();
d.notes.put_in_order();
DRAWING.upgrade(&mut v, version, applied)?;
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
d.validate().map_err(damaged)?;
d.check_image_files().map_err(damaged)?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
Ok(d)
}
/// A drawing a newer build saved: it opens with what this build can read of it. Everything in a
/// drawing refuses a field it doesn't know, so what it can't read shows as a top-level field,
/// a source or a sheet that doesn't read, which stays out and is named in
/// [`NewerDrawing::not_understood`]; [`Drawing::to_json`] writes it all back, with the file's
/// format. It takes at least one sheet this build can read to open.
fn from_newer(mut v: Value, version: u32, applied: Option<Vec<String>>, newer: crate::format::Newer) -> Result<Drawing> {
let list = applied.as_deref();
let can_read = |v: Value| Self::decode(v, version, list).map_err(|e| e.to_string());
let mut file = NewerDrawing {
format: newer.format,
reads_up_to: newer.reads_up_to,
unknown_changes: newer.unknown_changes.clone(),
..NewerDrawing::default()
};
let mut read = can_read(v.clone());
if read.is_err() {
let top = v.as_object_mut().ok_or_else(|| damaged("expected a JSON object"))?;
let keys: Vec<String> = top.keys().cloned().collect();
for key in keys {
if !RECORD_FIELDS.contains(&key.as_str())
&& let Some(value) = top.remove(&key)
{
file.not_understood.push(format!("field `{key}`"));
file.fields.push((key, value));
}
}
take_unread::<Sheet>(top, "sheets", "sheet", &mut file);
take_unread::<Source>(top, "sources", "source", &mut file);
read = can_read(v);
}
let d = read.map_err(|why| {
DocError::Invalid(format!(
"this drawing was saved by a newer SolidCraft (format {}, this version reads up to {}) and this version can't read it: {why}. Update SolidCraft to open it",
newer.format, newer.reads_up_to
))
})?;
let (format, format_pending) = if newer.format > newer.reads_up_to {
(DRAWING.format_string(newer.format), applied.unwrap_or_default())
} else {
let mut pending = DRAWING.pending_names();
pending.extend(newer.unknown_changes.iter().cloned());
(DRAWING.format_string(DRAWING.current()), pending)
};
Ok(Drawing { format, format_pending, newer_file: Some(Arc::new(file)), ..d })
}
/// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes
@@ -379,7 +314,15 @@ impl Drawing {
/// properties are kept as they came, and indenting them can make a drawing that read in
/// under the limit save over it.
pub fn to_json(&self) -> Result<String> {
let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?;
let unsaved = |e: serde_json::Error| DocError::Invalid(format!("drawing not saved: {e}"));
let text = match &self.newer_file {
None => serde_json::to_string_pretty(self).map_err(unsaved)?,
Some(file) => {
let mut value = serde_json::to_value(self).map_err(unsaved)?;
file.restore(&mut value);
serde_json::to_string_pretty(&value).map_err(unsaved)?
}
};
require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?;
Ok(text)
}
@@ -390,105 +333,21 @@ impl Drawing {
id_source.next(&self.ids().collect())
}
/// The position of the sheet `named` names: by id, else by a name only one sheet has.
pub fn sheet_index(&self, named: &str) -> Result<usize> {
if let Some(i) = ids::decode(named).and_then(|id| self.sheets.iter().position(|s| s.id == id)) {
return Ok(i);
}
let mut called = self.sheets.iter().enumerate().filter(|(_, s)| s.name == named).map(|(i, _)| i);
match (called.next(), called.next()) {
(Some(i), None) => Ok(i),
(Some(_), Some(_)) => Err(DocError::Invalid(format!("more than one sheet is called `{named}`: give its id"))),
(None, _) => Err(DocError::Invalid(format!("no sheet `{named}`"))),
}
}
/// Whether `id` names an object a sheet shows: a view, a dimension or a balloon.
pub fn has_object(&self, id: u64) -> bool {
self.view(id).is_some() || self.dimension(id).is_some() || self.balloons.by_id(id).is_some() || self.notes.by_id(id).is_some()
}
/// What is on sheet `id`: its views and the dimensions and balloons in them, and the images placed on it.
/// Each later kind of record on a sheet adds its own here, and goes with the sheet
/// ([`Drawing::remove_sheet`]).
pub fn on_sheet(&self, id: u64) -> OnSheet {
// The views are listed by id, so the sheet's are too, and so are its images, revision tags, tables and notes.
let mut on = self.with_contents(self.views.iter().filter(|v| v.sheet == id).map(|v| v.id).collect());
on.images = self.sheet_images.iter().filter(|i| i.sheet == id).map(|i| i.id).collect();
on.tags = self.revisions.tags().iter().filter(|t| t.sheet == id).map(|t| t.id).collect();
on.tables = self.tables.iter().filter(|t| t.sheet == id).map(|t| t.id).collect();
let in_views = |n: &Note| n.anchor.view().is_some_and(|v| on.views.binary_search(&v).is_ok());
on.notes = self.notes.iter().filter(|n| n.anchor == Anchor::Sheet(id) || in_views(n)).map(|n| n.id).collect();
on
}
/// `views`, listed by id, with the dimensions and balloons in them. An image sits on a sheet, never in a
/// view, so there are none.
pub fn with_contents(&self, views: Vec<u64>) -> OnSheet {
let dimensions = self.dimensions.iter().filter(|d| views.binary_search(&d.view).is_ok()).map(|d| d.id).collect();
let balloons = self.balloons.iter().filter(|b| views.binary_search(&b.view).is_ok()).map(|b| b.id).collect();
let notes = self.notes.iter().filter(|n| n.anchor.view().is_some_and(|v| views.binary_search(&v).is_ok())).map(|n| n.id).collect();
OnSheet { views, dimensions, balloons, images: Vec::new(), tags: Vec::new(), tables: Vec::new(), notes }
}
/// Takes sheet `id` out with everything on it ([`Drawing::on_sheet`]), and answers what that
/// was. A view on another sheet projected from one of its views is left without its parent,
/// which [`Drawing::validate`] refuses: what becomes of it is the caller's to decide first.
pub fn remove_sheet(&mut self, id: u64) -> OnSheet {
let on = self.on_sheet(id);
self.sheets.retain(|s| s.id != id);
self.remove_objects(&on);
on
}
/// Takes `objects` out, each list by id: the one rule a sheet's delete and a selection's delete
/// remove by, with the image files nothing uses any more. As in [`Drawing::remove_sheet`], what is
/// left must still keep the record's rules.
pub fn remove_objects(&mut self, objects: &OnSheet) {
self.views.retain(|v| objects.views.binary_search(&v.id).is_err());
self.dimensions.retain(|d| objects.dimensions.binary_search(&d.id).is_err());
self.balloons.retain(|b| objects.balloons.binary_search(&b.id).is_err());
self.sheet_images.retain(|i| objects.images.binary_search(&i.id).is_err());
self.drop_unused_images();
self.tables.retain(|t| objects.tables.binary_search(&t.id).is_err());
self.notes.retain(|n| objects.notes.binary_search(&n.id).is_err());
// A balloon outlives the list it named, and goes back to the lists it could read.
for balloon in self.balloons.iter_mut() {
if balloon.parts_list.is_some_and(|list| objects.tables.binary_search(&list).is_ok()) {
Arc::make_mut(balloon).parts_list = None;
}
}
if !objects.tags.is_empty() {
Arc::make_mut(&mut self.revisions).delete_tags(&objects.tags);
}
}
/// Every object's id, of every kind: what a new id must differ from. Each later kind adds its
/// own here.
fn ids(&self) -> impl Iterator<Item = u64> {
std::iter::once(self.id)
.chain(self.sources.iter().map(|s| s.id))
.chain(self.sheets.iter().map(|s| s.id))
.chain(self.views.iter().map(|v| v.id))
.chain(self.dimensions.iter().map(|d| d.id))
.chain(self.balloons.iter().map(|b| b.id))
.chain(self.images.iter().map(|i| i.id))
.chain(self.sheet_images.iter().map(|i| i.id))
.chain(self.tables.iter().map(|t| t.id))
.chain(self.notes.iter().map(|n| n.id))
.chain(self.revisions.ids())
self.sources.iter().map(|s| s.id).chain(self.sheets.iter().map(|s| s.id))
}
/// The record's rules (the module's notes): what reading a drawing checks, and what every edit
/// The record's rules (the module's notes): what reading a file checks, and what every edit
/// keeps.
pub fn validate(&self) -> Result<()> {
require((1..=MAX_SHEETS).contains(&self.sheets.len()), || format!("a drawing has 1 to {MAX_SHEETS} sheets"))?;
require(self.sources.len() <= 1, || "a drawing draws only the design it is in".into())?;
require(self.sources.len() <= MAX_SOURCES, || format!("a drawing draws at most {MAX_SOURCES} designs"))?;
check_name(&self.name, "the drawing's")?;
require(is_language_tag(&self.language), || {
format!("the language is not a BCP 47 tag of 2 to {MAX_LANGUAGE_CHARS} characters (such as `en` or `en-GB`)")
})?;
solidcraft_sketch::check_family(&self.font).map_err(|e| DocError::Invalid(format!("the drawing's font: {e}")))?;
if let Units::Fixed { unit } = &self.units {
// A length unit of the expression unit table, written as the table writes it.
let length = unit.trim() == unit.as_str() && expr::unit_info(unit).is_some_and(|(kind, _)| kind == expr::Kind::Length);
@@ -499,38 +358,25 @@ impl Drawing {
require(id != 0, || "an object has the id 0, which is never one".into())?;
require(seen.insert(id), || format!("two objects have the id {}", ids::encode(id)))?;
}
for source in &self.sources {
let path = &source.path;
require(!path.is_empty() && path.len() <= MAX_PATH_BYTES && !path.contains('\0'), || {
format!("source {}: a design's path is 1 to {MAX_PATH_BYTES} bytes, without NUL", ids::encode(source.id))
})?;
}
for sheet in &self.sheets {
self.check_sheet(sheet).map_err(|e| DocError::Invalid(format!("sheet {}: {e}", ids::encode(sheet.id))))?;
}
// One record for the whole drawing until sheets keep their own (#3874).
require(!self.revisions.per_sheet, || "a drawing keeps one revision record for all its sheets".into())?;
self.revisions.validate().map_err(|e| DocError::Invalid(e.to_string()))?;
require(self.revisions.tags().iter().all(|t| self.sheets.iter().any(|s| s.id == t.sheet)), || {
"a revision tag is on one of the drawing's sheets".into()
})?;
self.check_views()?;
self.check_dimensions()?;
self.check_tables()?;
self.check_balloons()?;
self.check_images()?;
self.check_notes()
Ok(())
}
/// A sheet's own rules, and that the source it documents is one of the drawing's.
fn check_sheet(&self, sheet: &Sheet) -> Result<()> {
check_name(&sheet.name, "its")?;
sheet.sheet.size_mm().map_err(from_sheet)?;
sheet.title_block_outline()?;
if let Some(block) = &sheet.title_block {
fields::check_block(block)?;
if let Some(border) = &sheet.border {
border.validate().map_err(from_sheet)?;
}
// Each override names a text cell of its block, and its text is a cell's.
for name in sheet.title_block_overrides.keys() {
let named = || format!("its title block has no text cell `{name}` to override");
require(fields::overridable(sheet.title_block.as_ref(), name), named)?;
}
let texts = sheet.title_block_overrides.values().map(String::as_str);
solidcraft_geom::cell::check_text(texts).map_err(|e| DocError::Invalid(e.to_string()))?;
require((MIN_DPI..=MAX_DPI).contains(&sheet.dpi), || format!("its pictures are {MIN_DPI} to {MAX_DPI} dpi"))?;
match sheet.documents {
Some(SheetComponent { source, .. }) => {
@@ -540,89 +386,6 @@ impl Drawing {
None => Ok(()),
}
}
/// How a message names this drawing: by its name and id.
pub fn label(&self) -> String {
format!("drawing {:?} ({})", self.name, ids::encode(self.id))
}
}
/// A design's drawings as read, each checked on its own already: in their order (by name, then id)
/// and keeping [`check_in_design`]'s rules.
pub fn in_design(drawings: Vec<Drawing>) -> Result<Vec<Arc<Drawing>>> {
let mut drawings: Vec<_> = drawings.into_iter().map(Arc::new).collect();
sort(&mut drawings);
check_in_design(&drawings)?;
Ok(drawings)
}
/// Put a design's drawings in their order: by name, then id. Nothing stores the order, so two
/// people adding drawings have nothing to merge.
pub fn sort(drawings: &mut [Arc<Drawing>]) {
drawings.sort_by(|a, b| (&a.name, a.id).cmp(&(&b.name, b.id)));
}
/// The rules for a design's drawings: at most [`MAX_DRAWINGS`], and no two with one id.
pub fn check_in_design(drawings: &[Arc<Drawing>]) -> Result<()> {
check_count(drawings.len())?;
let mut seen = BTreeSet::new();
drawings.iter().try_for_each(|d| take_id(&mut seen, d.id))
}
/// A drawing takes `id`, which no drawing before it in its design (`seen`) has.
fn take_id(seen: &mut BTreeSet<u64>, id: u64) -> Result<()> {
require(seen.insert(id), || format!("two drawings have the id {}", ids::encode(id)))
}
/// A design of `count` drawings keeps the cap: what a reader checks before it reads any of them.
pub fn check_count(count: usize) -> Result<()> {
require(count <= MAX_DRAWINGS, || format!("a design holds at most {MAX_DRAWINGS} drawings"))
}
/// Whether the drawing has no properties, so its file leaves them out.
fn no_properties(properties: &Arc<Metadata>) -> bool {
properties.is_empty()
}
/// Adds `more` to `records`, each where its id puts it: the order every list of records after the
/// sheets keeps (the module's rule), whatever order they come in.
fn add_by_id<T>(records: &mut Vec<Arc<T>>, more: impl IntoIterator<Item = Arc<T>>, id: impl Fn(&T) -> u64) {
records.extend(more);
records.sort_by_key(|r| id(r));
}
/// Whether `records` are in the order [`add_by_id`] keeps.
fn in_id_order<T>(records: &[Arc<T>], id: impl Fn(&T) -> u64) -> bool {
records.is_sorted_by_key(|r| id(r))
}
fn no_revisions(revisions: &Arc<RevisionRecord>) -> bool {
**revisions == RevisionRecord::default()
}
/// `doc` without the drawings that break a rule, and a line naming each one left out and the
/// rule. Reading refuses a design whole for one such drawing, so a copy that must open again (an
/// autosave, a crash report's design) keeps the rest: the first [`MAX_DRAWINGS`], the first of
/// two with one id, and only those keeping their own rules and small enough to save.
pub fn openable(doc: &crate::Document) -> (Cow<'_, crate::Document>, Vec<String>) {
let (mut seen, mut left_out) = (BTreeSet::new(), Vec::new());
let kept: Vec<_> = doc
.drawings
.iter()
.filter(|d| {
// Its own rules and its size before its id: a drawing left out takes no id from a later one.
let checked =
check_count(seen.len() + 1).and_then(|()| d.validate()).and_then(|()| d.to_json().map(drop)).and_then(|()| take_id(&mut seen, d.id));
checked.map_err(|e| left_out.push(format!("{} left out: {e}", d.label()))).is_ok()
})
.cloned()
.collect();
if left_out.is_empty() {
return (Cow::Borrowed(doc), left_out);
}
let mut doc = doc.clone();
doc.drawings = kept;
(Cow::Owned(doc), left_out)
}
/// `Ok` when `ok`, else the rule `broken` names.
-178
View File
@@ -1,178 +0,0 @@
//! A drawing's balloons (W26, #3874): each points at a part placed in its view's model, by an edge
//! of it, to number that part on the drawing. A balloon saves what it points at by name, never a
//! picked point, as a dimension does ([`super::dimension`]): when the model changes it follows the
//! part, or says what it lost, and it never moves to another part.
//!
//! - **The part** it numbers is the occurrence its edge's body is placed through: the full path,
//! from the top. A body of the design itself is not a placed part, so no balloon points at one.
//! - **The arrow** ends on the middle of its edge, whichever way the kernel runs the edge.
//! - **The balloon** sits at a place on paper, in millimetres from its view's centre, so it moves
//! with the view and not with the model.
//!
//! The number a balloon shows comes from a parts list, worked out and never saved: the one it names,
//! else its sheet's only one, else the drawing's only one ([`Drawing::parts_list_for`]). It keeps the
//! balloon whatever becomes of the row. A parts list's editor shows how many balloons point at each of
//! its rows ([`balloons_by_row`]), worked out the same way.
//!
//! The rules, beside the record's: a balloon is on one of the drawing's views; its edge reference
//! keeps a dimension's rules, with an occurrence path of 1 to [`crate::MAX_PLACEMENT_DEPTH`] ids; its place
//! is on paper ([`is_paper_coordinate`]); the list it names is one of the drawing's parts lists; at
//! most [`MAX_BALLOONS`] balloons, listed by id.
//!
//! [`Document::resolve_balloons`] finds each balloon's edge again as a dimension's is found
//! ([`super::dimension::Status`]): by name, strictly, and what it can't decide it says.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
use solidcraft_geom::drawing_view::is_paper_coordinate;
use solidcraft_geom::paper::MAX_SIDE_MM;
use super::by_id::Record;
use super::dimension::{Lookup, ModelEdge, Status, check_edge};
use super::table::{Kind, PartsList, SheetTable};
use super::{Drawing, require};
use crate::bom::{Bom, Tree, item_text};
use crate::{DocError, Document, Model, Result, ids};
/// The most balloons a drawing may have.
pub const MAX_BALLOONS: usize = 10_000;
/// One balloon of a drawing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Balloon {
#[serde(with = "crate::ids")]
pub id: u64,
/// The view it points into and moves with.
#[serde(with = "crate::ids")]
pub view: u64,
/// The edge its arrow points at. Its occurrence path is the part the balloon numbers.
pub edge: ModelEdge,
/// The balloon's centre, in millimetres of paper from its view's centre.
pub at: [f64; 2],
/// The parts list it names, when it names one.
#[serde(default, with = "crate::ids::option", skip_serializing_if = "Option::is_none")]
pub parts_list: Option<u64>,
}
impl Balloon {
/// The part it numbers: the occurrences its edge's body is placed through, from the top.
pub fn occurrence(&self) -> &[u64] {
&self.edge.path
}
}
impl Record for Balloon {
fn id(&self) -> u64 {
self.id
}
}
impl Drawing {
/// The parts list balloon `b` reads, by its id: the one it names, else its sheet's only one, else
/// the drawing's only one. With none to go by, the lists it could name, by id.
pub fn parts_list_for(&self, b: &Balloon) -> std::result::Result<(u64, PartsList), Vec<u64>> {
let list = |t: &SheetTable| match t.kind {
Kind::PartsList(list) => Some((t.id, list)),
Kind::Revision => None,
};
let lists: Vec<(u64, u64, PartsList)> = self.tables.iter().filter_map(|t| list(t).map(|(id, l)| (id, t.sheet, l))).collect();
if let Some(named) = lists.iter().find(|(id, ..)| Some(*id) == b.parts_list) {
return Ok((named.0, named.2));
}
let sheet = self.view(b.view).map(|v| v.sheet);
let mut on_sheet = lists.iter().filter(|(_, on, _)| Some(*on) == sheet);
if let (Some(only), None) = (on_sheet.next(), on_sheet.next()) {
return Ok((only.0, only.2));
}
match lists.as_slice() {
[only] => Ok((only.0, only.2)),
_ => Err(lists.iter().map(|(id, ..)| *id).collect()),
}
}
/// The balloons that read each parts list ([`Self::parts_list_for`]), by the list's id: each
/// balloon's list worked out once. A balloon with no list to go by reads none.
pub fn balloons_by_list(&self) -> HashMap<u64, Vec<&Balloon>> {
let mut by_list: HashMap<u64, Vec<&Balloon>> = HashMap::new();
for b in &self.balloons {
if let Ok((list, _)) = self.parts_list_for(b) {
by_list.entry(list).or_default().push(b);
}
}
by_list
}
/// The balloons' rules (the module's notes). The views and the tables are checked first, so they
/// are in id order here.
pub(super) fn check_balloons(&self) -> Result<()> {
self.balloons.check(MAX_BALLOONS, "balloon", |b| self.check_balloon(b))
}
fn check_balloon(&self, b: &Balloon) -> Result<()> {
require(self.view(b.view).is_some(), || format!("it is on view {}, which the drawing doesn't have", ids::encode(b.view)))?;
if let Some(list) = b.parts_list {
let named = || format!("it names table {}, which isn't one of the drawing's parts lists", ids::encode(list));
require(self.tables.by_id(list).is_some_and(|t| matches!(t.kind, Kind::PartsList(_))), named)?;
}
check_edge(&b.edge)?;
require(!b.edge.path.is_empty(), || "it points at a placed part: its edge's body is placed through an occurrence".into())?;
let [x, y] = b.at;
require(is_paper_coordinate(x) && is_paper_coordinate(y), || format!("its place is at most {MAX_SIDE_MM} mm on paper"))
}
}
/// How many of `balloons`, those that read one parts list ([`Drawing::balloons_by_list`]), point at
/// each of its rows, by item ("1.2"): every row of `listed` (that list, made from `tree` with its
/// members) in its order, 0 included. A balloon whose row is lost counts for none.
pub fn balloons_by_row(balloons: &[&Balloon], listed: &Bom, tree: &Tree) -> Vec<(String, usize)> {
let mut counts: HashMap<String, usize> = HashMap::new();
for b in balloons {
if let Ok(item) = listed.item_of(tree, b.occurrence()) {
*counts.entry(item).or_default() += 1;
}
}
listed
.rows_deep()
.iter()
.map(|(item, _)| {
let item = item_text(item);
let n = counts.get(&item).copied().unwrap_or(0);
(item, n)
})
.collect()
}
/// A balloon as the model has it now ([`Document::resolve_balloons`]): never saved.
#[derive(Clone, Debug, PartialEq)]
pub struct Resolved {
pub status: Status,
/// Where its arrow ends, the middle of its edge, in the design's frame: when the edge was found.
pub point: Option<Vec3>,
/// Its edge was found by exact history, as one edge.
pub exact: bool,
}
impl Document {
/// The balloons `wanted` of `drawing` (one of this design's drawings) as `model` (this
/// design's) has them now: each one's edge found again by name, and the middle of it. A body is
/// looked up and its edge names worked out once for the call, however many balloons name it.
/// `Err` only for an id the drawing doesn't have.
pub fn resolve_balloons(&self, model: &Model, drawing: &Drawing, wanted: &[u64]) -> Result<Vec<Resolved>> {
let mut lookup = Lookup::new(self, model);
wanted
.iter()
.map(|id| {
let b = drawing.balloons.by_id(*id).ok_or_else(|| DocError::Unknown(format!("balloon {}", ids::encode(*id))))?;
let (status, point, exact) = lookup.middle(&b.edge);
Ok(Resolved { status, point, exact })
})
.collect()
}
}
#[cfg(test)]
#[path = "balloon_tests.rs"]
mod tests;
-264
View File
@@ -1,264 +0,0 @@
use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
use solidcraft_geom::drawing_view::NamedView;
use super::*;
use crate::drawing::table::{Kind, SheetTable};
use crate::drawing::view::{View, ViewKind, ViewPlace};
use crate::drawing::{OnSheet, Sheet, Source};
use crate::drawing_view::{ViewDirection, ViewOverrides};
use crate::format::DRAWING;
use crate::ids::IdSource;
const SEED: u64 = 3874;
fn edge(name: &str, path: Vec<u64>) -> ModelEdge {
ModelEdge { body: "Bolt".into(), origin_feature: 4, name: name.into(), path }
}
/// A drawing of one design on one A3 sheet, its front view at (150, 150), and on the view two
/// balloons: one on a bolt placed straight in the design (occurrence 3), one on a bolt inside a
/// sub-assembly (occurrence 7 in 3). Their ids, in the order they were added.
fn ballooned() -> (Drawing, [u64; 2]) {
let mut id_source = IdSource::seeded(SEED);
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
let mut d = Drawing::new("Bolted", "en", "Sheet 1", a3, None, &BTreeSet::new(), &mut id_source).unwrap();
let source = d.allocate_id(&mut id_source).unwrap();
d.sources.push(Source { id: source });
let (view, sheet) = (d.allocate_id(&mut id_source).unwrap(), d.sheets[0].id);
let kind = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
d.add_views([Arc::new(View {
id: view,
sheet,
kind,
place: ViewPlace::At([150.0, 150.0]),
settings: ViewOverrides { scale: Some("1:2".parse().unwrap()), ..ViewOverrides::default() },
reference_shown_percent: 0.0,
})]);
let mut added = [0; 2];
for ((path, at), id) in [(vec![3], [40.0, 25.0]), (vec![3, 7], [-35.5, 60.0])].into_iter().zip(&mut added) {
*id = d.allocate_id(&mut id_source).unwrap();
d.balloons.add([Arc::new(Balloon { id: *id, view, edge: edge("F1:side|F2:head", path), at, parts_list: None })]);
}
d.validate().unwrap();
(d, added)
}
fn file(d: &Drawing) -> Value {
serde_json::from_str(&d.to_json().unwrap()).unwrap()
}
/// Balloon `id` in a drawing's file.
fn balloon<'a>(v: &'a mut Value, id: &str) -> &'a mut Value {
v["balloons"].as_array_mut().unwrap().iter_mut().find(|x| x["id"] == id).unwrap()
}
/// Why `d`'s file, changed by `edit`, is refused, or "read".
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
let mut v = file(d);
edit(&mut v);
Drawing::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |_| "read".into())
}
/// A balloon saves its view, its edge by name with the part's occurrence path, and its place, and
/// nothing worked out. Balloons save by id and read back as saved, the same bytes again; a file
/// that lists them in another order reads by id. An older drawing reads with none, and a drawing
/// with none writes none.
#[test]
fn balloons_save_what_they_point_at_and_read_back_as_saved() {
let (d, added) = ballooned();
let [placed, nested] = added.map(ids::encode);
let view = ids::encode(d.views[0].id);
let mut v = file(&d);
assert_eq!(
*balloon(&mut v, &nested),
json!({
"id": nested, "view": view,
"edge": {"body": "Bolt", "origin_feature": 4, "name": "F1:side|F2:head", "path": [3, 7]},
"at": [-35.5, 60.0]
})
);
assert_eq!(balloon(&mut v, &placed)["edge"]["path"], json!([3]));
assert_eq!(d.balloons.by_id(added[1]).unwrap().occurrence(), [3, 7], "the part it numbers is its edge's path");
let listed: Vec<u64> = v["balloons"].as_array().unwrap().iter().map(|x| ids::decode(x["id"].as_str().unwrap()).unwrap()).collect();
let mut by_id = added.to_vec();
by_id.sort_unstable();
assert_eq!(listed, by_id);
let reopened = Drawing::from_json(&d.to_json().unwrap()).unwrap();
assert_eq!(reopened, d);
assert_eq!(reopened.to_json().unwrap(), d.to_json().unwrap());
let mut other_order = v.clone();
other_order["balloons"].as_array_mut().unwrap().reverse();
assert_eq!(Drawing::from_json(&other_order.to_string()).unwrap(), d);
let mut unsorted = d.clone();
unsorted.balloons.reverse();
assert_eq!(unsorted.validate().unwrap_err().to_string(), "a drawing lists its balloons by id");
let mut none = d.clone();
none.balloons.clear();
assert_eq!(file(&none).get("balloons"), None, "no balloons, none written");
let older = json!({
"format": DRAWING.format_string(1), "id": "0000000000002", "name": "Old", "sources": [], "language": "en",
"sheets": [{"id": "0000000000001", "name": "S", "sheet": {"size": {"standard": "a4"}}}]
});
assert!(Drawing::from_json(&older.to_string()).unwrap().balloons.is_empty());
}
/// What reading refuses, each rule by name, on both sides of every cap: the view a balloon is on,
/// its edge's names, feature and path (a placed part's: 1 to 64 occurrences, none of them 0), its
/// place on paper, its id, how many there are, and fields it doesn't know.
#[test]
fn the_balloons_rules_are_kept_on_reading() {
let (d, added) = ballooned();
let [placed, nested] = added.map(ids::encode);
let rule = |id: &str, rule: &str| format!("damaged drawing: balloon {id}: {rule}");
let nothing = ids::encode(99);
let on = format!("it is on view {nothing}, which the drawing doesn't have");
assert_eq!(refused(&d, |v| balloon(v, &placed)["view"] = json!(nothing)), rule(&placed, &on));
// The edge keeps a dimension's reference rules.
let names = "a name in a reference is 1 to 512 bytes, without control characters";
let body = |name: &str| refused(&d, |v| balloon(v, &nested)["edge"]["body"] = json!(name));
let edge_name = |name: &str| refused(&d, |v| balloon(v, &nested)["edge"]["name"] = json!(name));
for fits in ["x".repeat(512), "é".repeat(256)] {
assert_eq!([body(&fits), edge_name(&fits)], ["read", "read"], "{} bytes", fits.len());
}
for broken in ["x".repeat(513), String::new(), "two\nlines".into()] {
assert_eq!([body(&broken), edge_name(&broken)], [rule(&nested, names), rule(&nested, names)], "{} bytes", broken.len());
}
let feature = rule(&placed, "a reference names the feature that made its body");
assert_eq!(refused(&d, |v| balloon(v, &placed)["edge"]["origin_feature"] = json!(0)), feature);
// A placed part's path: 1 to 64 occurrences, none of them 0. A design's own body is no part.
let path = |p: Value| refused(&d, |v| balloon(v, &nested)["edge"]["path"] = p);
let deep = rule(&nested, "an occurrence path is at most 64 occurrences, none of them 0");
let own = rule(&nested, "it points at a placed part: its edge's body is placed through an occurrence");
assert_eq!(
[path(json!([3])), path(json!(vec![3; 64])), path(json!(vec![3; 65])), path(json!([3, 0])), path(json!([]))],
["read".to_string(), "read".into(), deep.clone(), deep, own.clone()]
);
let no_path = refused(&d, |v| {
balloon(v, &nested)["edge"].as_object_mut().unwrap().remove("path");
});
assert_eq!(no_path, own, "no path");
// Its place is on paper.
let paper = rule(&placed, "its place is at most 5000 mm on paper");
let at = |at: [f64; 2]| refused(&d, |v| balloon(v, &placed)["at"] = json!(at));
assert_eq!([at([5000.0, -5000.0]), at([5000.001, 0.0]), at([0.0, -5000.001])], ["read".to_string(), paper.clone(), paper]);
assert!(refused(&d, |v| balloon(v, &placed)["at"] = json!([1.0])).starts_with("damaged drawing: "), "two numbers");
// Its id is the drawing's: never another object's.
let view_id = ids::encode(d.views[0].id);
assert_eq!(refused(&d, |v| balloon(v, &placed)["id"] = json!(view_id)), format!("damaged drawing: two objects have the id {view_id}"));
// Fields it doesn't know, at every level.
let unknown: [fn(&mut Value, &str); 3] = [
|v, id| balloon(v, id)["item"] = json!(3),
|v, id| balloon(v, id)["edge"]["point"] = json!([0.0, 0.0, 0.0]),
|v, id| balloon(v, id)["shape"] = json!("circle"),
];
for (i, edit) in unknown.into_iter().enumerate() {
assert!(refused(&d, |v| edit(v, &placed)).starts_with("damaged drawing: "), "{i}");
}
// At most MAX_BALLOONS balloons.
let many = |n: usize| {
let mut v = file(&d);
let one = balloon(&mut v, &placed).clone();
let copies = (1..=n as u64).map(|i| {
let mut copy = one.clone();
copy["id"] = json!(ids::encode(i));
copy
});
v["balloons"] = Value::Array(copies.collect());
Drawing::from_json(&v.to_string()).map(|d| d.balloons.len()).map_err(|e| e.to_string())
};
assert_eq!(many(MAX_BALLOONS), Ok(MAX_BALLOONS));
assert_eq!(many(MAX_BALLOONS + 1), Err(format!("damaged drawing: a drawing has at most {MAX_BALLOONS} balloons")));
}
/// A sheet's balloons are its views' and go with it; a new id never takes a balloon's.
#[test]
fn a_sheets_balloons_go_with_it() {
let (mut d, added) = ballooned();
let sheet = d.sheets[0].id;
let on = d.on_sheet(sheet);
let mut by_id = added.to_vec();
by_id.sort_unstable();
assert_eq!(on.balloons, by_id);
let mut id_source = IdSource::seeded(SEED);
for _ in 0..64 {
let id = d.allocate_id(&mut id_source).unwrap();
assert!(!added.contains(&id), "a new id never takes a balloon's");
}
let second = d.allocate_id(&mut id_source).unwrap();
d.sheets.push(Arc::new(crate::drawing::Sheet { id: second, name: "Sheet 2".into(), ..(*d.sheets[0]).clone() }));
assert_eq!(d.remove_sheet(sheet), on);
d.validate().unwrap();
assert!(d.balloons.is_empty() && d.views.is_empty());
}
/// A parts list on `d`'s sheet `sheet`, with a fresh id.
fn add_list(d: &mut Drawing, sheet: u64, id: u64) {
use crate::drawing::SheetComponent;
use crate::drawing::table::{MassFormat, PartsList};
let of = SheetComponent { source: d.sources[0].id, component: 0 };
let list =
PartsList { of, view: Default::default(), group: Default::default(), fold: false, purchased_contents: false, mass: MassFormat::default() };
let kind = Kind::PartsList(list);
d.tables.add([Arc::new(SheetTable { id, sheet, table: kind.default_table(), place: kind.default_place(), kind })]);
}
/// With no link a balloon reads its sheet's only list, else the drawing's only one, else none, with
/// the choices listed; a revision table is no choice. Its link wins. Saved only when it names one.
#[test]
fn a_balloon_reads_the_list_it_names_else_the_only_one() {
let (mut d, [id, _]) = ballooned();
let sheet = d.sheets[0].id;
let read = |d: &Drawing| d.parts_list_for(d.balloons.by_id(id).unwrap()).map(|(list, _)| list);
assert_eq!(read(&d), Err(vec![]));
let saved: Value = serde_json::from_str(&d.to_json().unwrap()).unwrap();
assert_eq!(saved["balloons"][0].get("parts_list"), None);
let other = 1 << 40;
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
d.sheets.push(Arc::new(Sheet { id: other, name: "Other".into(), sheet: a4, ..(*d.sheets[0]).clone() }));
add_list(&mut d, other, 1 << 41);
assert_eq!(read(&d), Ok(1 << 41), "the drawing's only one");
add_list(&mut d, sheet, 1 << 42);
assert_eq!(read(&d), Ok(1 << 42), "the sheet's only one comes first");
add_list(&mut d, sheet, 1 << 43);
assert_eq!(read(&d), Err(vec![1 << 41, 1 << 42, 1 << 43]), "the choices");
assert!(d.balloons_by_list().is_empty(), "with none to go by, a balloon reads no list");
let kind = Kind::Revision;
d.tables.add([Arc::new(SheetTable { id: 1 << 44, sheet, table: kind.default_table(), place: kind.default_place(), kind })]);
assert_eq!(read(&d).unwrap_err().len(), 3, "a revision table is no choice");
let at = d.balloons.index_of(id).unwrap();
Arc::make_mut(&mut d.balloons[at]).parts_list = Some(1 << 41);
assert_eq!(read(&d), Ok(1 << 41), "its link wins");
let by_list = d.balloons_by_list();
assert_eq!((by_list.len(), by_list[&(1 << 41)].iter().map(|b| b.id).collect::<Vec<_>>()), (1, vec![id]), "the other reads none");
d.validate().unwrap();
assert_eq!(Drawing::from_json(&d.to_json().unwrap()).unwrap(), d);
// The list goes, by its own delete or its sheet's: the balloon stays and reads what it can.
d.remove_objects(&OnSheet { tables: vec![1 << 41], ..Default::default() });
assert_eq!(d.balloons.by_id(id).unwrap().parts_list, None);
d.validate().unwrap();
assert_eq!(read(&d).unwrap_err(), vec![1 << 42, 1 << 43]);
}
/// A link names one of the drawing's parts lists.
#[test]
fn a_balloons_link_is_to_a_parts_list() {
let (mut d, [id, _]) = ballooned();
let sheet = d.sheets[0].id;
let kind = Kind::Revision;
d.tables.add([Arc::new(SheetTable { id: 1 << 44, sheet, table: kind.default_table(), place: kind.default_place(), kind })]);
let at = d.balloons.index_of(id).unwrap();
for bad in [1 << 44, 99] {
Arc::make_mut(&mut d.balloons[at]).parts_list = Some(bad);
assert!(d.validate().unwrap_err().to_string().contains("parts lists"), "{bad}");
}
let mut v: Value = serde_json::from_str(&Drawing { balloons: Default::default(), ..d.clone() }.to_json().unwrap()).unwrap();
v["balloons"] = json!([{"id": "x", "view": "x", "edge": {}, "at": [0.0, 0.0], "parts_list": 7}]);
assert!(Drawing::from_value(v).is_err(), "not an id");
}

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