Compare commits

...

46 Commits

Author SHA1 Message Date
CamWight d78895de4d Where a drawing stands: drawing.status in one read (#4894 WE2) (#11352)
drawing.status says each view's status (current, out of date, placeholder or
failed, with why) from what the sheet page last drew, and each dimension's in the
words inspect uses, reading each body once for the call. Nothing is drawn.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 04:41:33 -05:00
CamWight 112e0a3d9c Paper text in SVG: a run's glyph outlines on the writer's grid, filled nonzero (#4890 B2a) (#9708)
The walk puts a text run on the grid as its glyphs' outlines, placed by the run
(`TextRun::outlines`), each loop walked as a fill's: `GridItem::Text`, with the run's
index among its group's items, so a writer that sets real text (PDF) finds it in paint
order. SVG writes one path a run, filled black with its default nonzero rule, so text
looks the same without the font and never reflows. The sheet PNG refuses text by name
until B2b, and its refusal test says so.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 02:09:25 -05:00
CamWight eb0b427a36 Drawings WE3-WE5: commands report where each object sits on a sheet, and select and delete views, dimensions and balloons (#4894) (#11047)
* Where a sheet's objects sit: bounds beside the scene (#4894 WE3)

The sheet scene now says where its border and each view sit on paper: the box
round what each draws, and a view's boundary, so one that can't be drawn can
still be picked. It is worked out once per assembly, beside the scene rather
than in it, so no writer sees it and the page needn't inspect each view.

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

* Selected drawing objects: a view, dimension or balloon in the selection (#4894 WE4)

select.set takes {type: drawing, drawing, object} for the drawing open. It adds no
undo step and changes nothing saved, and a selected object leaves the selection
when an undo takes it or its drawing closes.

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

* Delete on a sheet: views, dimensions and balloons in one step (#4894 WE5)

selection.delete now works with a drawing open: a view goes with its dimensions
and balloons as one undo step, and one with views projected from it is refused,
naming them, unless they are selected too or contents: delete takes them. A sheet's
delete and this one share the record's one removal rule.

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

* Tests: a view's box holds what it draws, and what's chosen goes in any order (#4894 WE3, WE5)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:59:41 -05:00
CamWight ba69e5488c Drawings WE1: Inspect Drawing lists each sheet's views, with their names, dimensions and balloons (#4894) (#11350)
* Where a sheet's objects sit: bounds beside the scene (#4894 WE3)

The sheet scene now says where its border and each view sit on paper: the box
round what each draws, and a view's boundary, so one that can't be drawn can
still be picked. It is worked out once per assembly, beside the scene rather
than in it, so no writer sees it and the page needn't inspect each view.

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

* Selected drawing objects: a view, dimension or balloon in the selection (#4894 WE4)

select.set takes {type: drawing, drawing, object} for the drawing open. It adds no
undo step and changes nothing saved, and a selected object leaves the selection
when an undo takes it or its drawing closes.

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

* Delete on a sheet: views, dimensions and balloons in one step (#4894 WE5)

selection.delete now works with a drawing open: a view goes with its dimensions
and balloons as one undo step, and one with views projected from it is refused,
naming them, unless they are selected too or contents: delete takes them. A sheet's
delete and this one share the record's one removal rule.

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

* Tests: a view's box holds what it draws, and what's chosen goes in any order (#4894 WE3, WE5)

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

* What's on each sheet: drawing.inspect lists its views (#4894 WE1)

Each sheet in drawing.inspect now lists its views: each base view by id, followed
by those projected from it, with what it is called and its dimensions and balloons.
It is one pass over the record with no model read, so the Browser can read it once
per revision.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:59:36 -05:00
CamWight 9308095744 Paper text in the browser: Japanese through the font the web app fetches (#4890 F6) (#10516)
The browser has no font folders, so `font_dirs` is empty there. The first
kana or Han a text's font lacks asks for the font the web app already
fetches on demand (BIZ UDPGothic); no new file. The text is pending until
it comes, then sets whatever it has a glyph for. If the fetch fails, or
the font lacks a character, that character is refused by name, and the
refusal says the web covers Japanese for now.

The web app starts the fetch through its existing loader, once, when a
text waits for the font, and hands the font over when it arrives.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:59:34 -05:00
CamWight 5bf3940c04 Sheet tables on paper: a table's lines (#3874, #4890 W10.7, D3a) (#7745)
TableLayout::paper gives a placed table's lines as one paper group of
the kind it is (parts_list or revision_table), so both sheet tables reach
paper through one function:

- the outline as one closed stroke, outline_line_mm wide;
- a stroke cell_line_mm wide across the whole table at each row boundary
  inside the outline, the heading's too, from the top whichever side the
  heading is on;
- a stroke down the whole table at each column boundary inside it.

Each line is drawn once, centred on its edge, on the layout's own
corners. The cells' text follows once the paper scene has text (D3b,
after #4813); hatch clearance waits for hatching.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:53:06 -05:00
CamWight 2f483a00c0 Drawing text F4: a character missing from the chosen font is drawn with another installed font (#4890) (#10515)
* Drawings: the font a drawing's text is set in, by family (#4890 F1)

A drawing names the font family its text is set in (`font`, saved only when
set; a pending drawing-format file). `drawing.create` and `drawing.set` take
it, and `drawing.inspect` reports it, with `font_note` when this computer sets
the text in the built-in font instead ("Futura isn't on this computer: set in
Liberation Sans"). Drawings name fonts by family, so they travel: a font file
is refused.

Paper text finds the family among the installed fonts as sketch text does
(`paper_text_in` for given folders), admits each face once a process, and
sets a family that is missing or can't be set on paper in the built-in font.
Bold takes the family's bold face; the serif flag comes from the face's OS/2
family class. The fixed list of drawing fonts is gone.

New drawings made in the app take the interface font when it is a family.

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

* Paper text: any face with outlines is set, and says how a PDF may carry it (#4890 F2)

A face is admitted for paper whatever its tables lack:
- a variable face is set at its default instance, which is what a PDF draws;
- its cap height is the one sketch text scales it by (OS/2, else the top of
  its `H`, else 70 % of the em);
- its PostScript name is what a PDF name can hold of its own, else of its
  family and style, else `Font-` and the start of its id;
- without a `post` table it has no italic angle, and a fixed pitch when every
  glyph advances alike;
- its stem width is measured across its `l`, else taken from its weight.
A bitmap or colour-only face, with no outlines to fill, is refused by name.

`Font.embed` says how a PDF may carry the face, from its embedding permissions:
a subset, the whole face (no subsetting), or not at all (restricted, bitmap
embedding only, or CFF2 outlines), when the PDF draws the runs' outlines with
their text beside them. Which characters a PDF name may hold is now one rule,
`is_postscript_char`, that the record's check and sketch share.

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

* Paper text: a font's family, for readers that pick fonts by family (#4890 F2)

`Font.family` is the face's own family name: its typographic family (name
ID 16), else its family (ID 1), read from the face itself (in a collection,
not the first face), without control characters and at most 255 characters;
empty when it has neither. A DXF text style picks a font by it; `Font.id`
stays the identity, so two faces of one family are still two fonts
(@0xlillith on #4890). The name lookup PostScript names use is one function
now, shared by both.

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

* Fonts: one list of fallback fonts per script, in sketch (#4890 F3)

The interface's lists of fonts to try for a script move down to
`sketch::fonts::fallback`, so drawings can use the same ones. Each row is
a family and the files it's known to be in. The interface reads the files
in the order it always did, so what it finds is unchanged.

- `CRAFT_FONTS_DIR` joins `font_dirs`, as the interface already has it:
  `SOLIDCRAFT_FONT_DIRS`, else the pack, then the system's folders.
- `font_files` sorts the files within each folder, so a search doesn't
  depend on how the system lists a folder.
- The Windows folder search takes its names from the Japanese list
  instead of its own copy of them.

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

* Paper text: a character its font lacks falls back to a font that has it (#4890 F4)

A text on paper was refused for any character its font lacks. Now each
such character falls back to an installed font that has it: the script's
list in `sketch::fonts::fallback` first, Han by the drawing's language,
then the first face in sorted order with outlines and the character.

- Runs split by direction, then by font. Every font takes the same em
  size, on the line's baseline and pitch. A space, a digit or punctuation
  stays with the run it follows when that font has it; a mark stays with
  its letter. A font fallen back to is shaped, with the drawing's
  language. Its nearest style is used: bold or italic isn't refused.
- A line its own font has all of is set as it was.
- A character no font has is still refused by name.
- The last-resort search reads each file's character map alone, stays
  within `MAX_FONT_FILES`, and keeps its answers, misses too, for the
  process.
- `paper_text_now` is for the window's thread: the search runs on a thread
  of its own (`font-search`) and it refuses with `FontPending` until the
  answers are in. `paper_text` searches where it's called.
- `paper_fallback_notes` names what fell back, for a drawing's status,
  from the answers so far: it looks for no font.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:27:16 -05:00
Nathan Dane d5fd14135f Add the native Drawing workspace UI scaffold (#5349)
* docs: plan drawing workspace UI implementation

* assets: add drawing icon drafts for owner review

* feat(ui): add drawing workspace scaffold with shared ribbon and docked settings

Expose planned Drawing tools without introducing drawing documents, paper rendering or CAD behavior. Reuse existing source BOM, properties and flat-pattern services; retain placeholders for unapproved icon drafts.

Validation: full UI, kernel, icon, clippy, wasm and layout checks passed. Full cargo xtask ci reports three geometry failures that also reproduce on unchanged drawings base 3b982ca: counterbored chamfered-disc ring cut, robustness seed 36 and seed 71. No geometry source or golden fixtures changed.

* Preserve default icon palette in Drawing scaffold tests

* Preserve Drawing providers and complete scaffold translations
2026-11-01 21:45:05 -05:00
CamWight 85be50df0d Reference parts: a view shows them by percent, not by a margin (#5866 V5c) (#10713)
reference_margin_mm becomes reference_shown_percent, 0 to 100. At 0 the
boundary is the model's box, as before, and no reference part is measured.
At 100 it holds the model and all the view's reference parts. Between, each
side moves on its own, so a part on one side grows only that side.

The model's middle now places a view, not the middle of what it shows, so a
view stays lined up with its projected views as its box grows. With nothing
grown the two are the same point and the picture is the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:36:53 -05:00
CamWight 71edae80df Drawing views made by command: drawing.view.base and drawing.view.projected (#4816) (#9254)
* Drawing views made by command: drawing.view.base and drawing.view.projected (#4816)

drawing.view.base {sheet, view?, scale?, position_mm?, the view settings?} adds a base view of the
drawing's design to a sheet. Without a scale it takes the largest ISO 5455 preset at which it lies
inside the sheet's drawing space, centred where it goes, its margin included, as the snapshot fits
it; that scale is saved as the view's own (1:1 with nothing to measure).

drawing.view.projected {parent, side, place?} adds a view projected from a saved view, on its
parent's sheet, following its parent's scale and display settings. Without a place it is lined up
with its parent, its boundary 20 mm past the parent's (DEFAULT_GAP_MM, until a drawing style
gives it).

Each is one undo step through edit_drawing, refused with nothing changed when the record's rules
refuse it; the reply gives the view, its sheet, its scale, its boundary and, when it leaves the
sheet's drawing space, itself as outside (it isn't moved back in). Unknown fields are refused.

One rule, one place: whether a view leaves its drawing space is drawing_view::leaves, and the sheet
of an id is drawing_view::sheet, used by outside() and both commands.

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

* Drawing views made by command: a build fix, and each view one undo step, tested (#4816)

From the review of the view create commands:
- drawing.view.base measures the shown bodies (`measure` takes them, not the session), so it builds.
- The nothing-to-measure case is a design with one sketch and no bodies, as the other view tests
  make it: drawing.create refuses an empty design.
- The schema test gives drawing.view.base its own key list (it takes no snapshot-only keys).
- Every view a test makes is checked to be one undo step, not only the first: a second step made
  under the first (a nested edit) still let one undo take the view away.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:12:38 -05:00
CamWight 93baf88e2d Paper scene: pictures in the sheet PNG and SVG (#5624, W17b S5) (#7591)
* Paper scene: one walk onto a writer's number grid, for every writer (#4890, C4)

The SVG writer kept its own number grid: each coordinate whole nanometres, every joint written once,
a closed path ending exactly on its first point, slivers merged into the next piece and a path's
last piece kept. PDF, PNG and the Drawing workspace's canvas need the same rules, so they move into
`geom::paper::grid`, and the SVG writer only formats what the walk gives it.

- `Grid`: `per_mm` steps a millimetre. `Grid::NANOMETRE` (six decimals of a millimetre) is SVG's.
  `Grid::new` takes 1 to 1e9; a power of two keeps every number exact as a binary float, which a
  writer of 32-bit numbers such as PDF can use.
- `Prepared::on_grid(grid) -> OnGrid`: each group's items as paths of `Piece`s (lines, arcs of at
  most a quarter turn, cubics, parabolas), y up as in the scene, a closed path's closing line left
  to the writer's close, and a report of widths snapped and pieces merged. Slivers are measured in
  the grid's own steps, so a coarser grid merges more.
- The SVG writer flips y on the whole numbers it's given and writes the same bytes as before.
  `SvgReport` is the grid's report.
- Ellipses, hyperbolas and Béziers above degree 3 are still refused (A3b), now by the walk.

DXF stays on its own number rule for now: it writes exact entities (ELLIPSE, SPLINE) and is being
reworked under #5318 to #5321; whether its polylines take a grid is decided there.

Tests: every SVG test unchanged, bytes included; the grid's rounding and its bounds; the A3 border
on the grid (y up, closing line left out, fill loops closed); slivers in the grid's own steps, with
the merge boundary both ways (10 and 11 steps, a 6-8 and a 7-8 step); an arc's quarters with their
radius and turn in steps; a width under half a step made one step; an ellipse refused.

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

* Paper scene: a sheet PNG at a chosen dpi and colour (#4890 A4)

A new writer, io::paper::paper_png, that reads the shared walk on the
nanometre grid, the one the SVG writer uses, so a PNG draws the same joints,
closures and merges. Neither the walk nor the SVG writer changes.

- Curves are flattened within 1/8 px by geom::paper::flatten, which turns
  each walked piece back into its Seg2 (an arc's centre from its ends, radius
  and direction) and takes Seg2::chords' steps. chords is the bound
  Seg2::polyline already used (the chord's bulge for arcs, Wang's bound for
  the rest), moved out of polyline unchanged so both read one rule.
  polyline itself doesn't fit: it floors an arc's tolerance at a millionth of
  its radius and caps a piece at 4096 steps, coarser than asked without a
  word (0.1 mm, 9 px, on a 100 m arc at 2400 dpi). Over 2^20 points a path,
  or 2^24 a sheet, is refused instead. tiny-skia only gets lines: its own
  flattening stops at 64 lines a curve, more than a pixel off on a large arc
  at a high dpi.
- 25 to 2400 dpi (300 by default). 8-bit greyscale (the default), 1-bit
  black and white (no anti-aliasing, every line at least a pixel wide), 24-bit
  colour, or 32-bit colour with transparent paper.
- The file records its dpi (pHYs) and sRGB, so it prints at the sheet's size.
- Each line is stroked once, then the sheet is drawn in bands of 16 MiB of
  pixels streamed into the file, so memory doesn't grow with the sheet. Each
  band also draws a row above and below that isn't written: tiny-skia starts
  an edge a little differently where a clip cuts it (one subsample, 16
  levels, in a few pixels), so no written row sits on a cut and the bytes are
  the same whatever the band.
- A line under a pixel wide (in greyscale and colour) is drawn as tiny-skia's
  hairline, its coverage scaled by its width: its stroked outline would fall
  between the rows the rasteriser samples and vanish.

Tests: the pixel size and pHYs of A4 at 300 and 25 dpi; a line's and a fill's
pixels at 254 dpi (butt ends, the fill's hole); each colour's PNG type, ink
and paper; a hairline between two rows' centres that is light grey in
greyscale and stays one pixel in black and white, and a bar a fifth of a pixel
wide that black and white inks by its centre; a closed outline joined at its
start; a 40 mm circle round to the pixel at 600 dpi; the same bytes drawn 1, 3
or 7 rows at a time or all at once, and again; the refusals; the flattener's
bounds and counts, and its point cap at the exact boundary; the scene-JSON
fuzz through the PNG writer.

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

* Geom: the pixel grid of a paper rectangle (#5624, W17b S1)

One rule for where a picture's pixels lie on paper, shared by the picture renderer and the writers
that place it: anchored at the rectangle's lower-left corner, rounded outward (W = ceil(w / p - 1e-9)
with p = 25.4 / dpi), so the overhang is at the top and the right; rows run top-down. At most
65,536 px a side; nothing caps the total, as pictures are drawn in tiles and strips.

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

* Paper scene: pictures, and a sheet's pixel grid and windows (#5624, W17b S3; #4890 A9)

A picture on a sheet is `Item::Picture`: the rectangle it sits in, the name of its pixels
(`source`, opaque as a group's object id is) and its opacity. The pixels stay out of the scene: a
writer asks a `Pictures` provider for them on the grid it draws on. A PNG at 1200 dpi then gets a
shaded view drawn at 1200 dpi, not one stretched, and a scene stays small, plain data.

The pixel grid gains the two rules a sheet needs, beside the rectangle's own grid:
- `PixelGrid::sheet`: the whole sheet's grid, anchored at its top-left corner, as an image of
  the sheet is drawn.
- `PixelGrid::window`: the columns and rows a rectangle meets; a side on a pixel edge takes no
  sliver beyond it. A picture drawn into its window of the sheet grid is placed by copying whole
  pixels.

`Picture::grid` is a picture's own grid at a dpi, for SVG and PDF to embed. `Scene::validate`
counts a picture as one record. It refuses a rectangle outside the coordinate bound or without
area, and an opacity that isn't above 0 and at most 1. `Aabb2` is read and written as
`{min, max}`.

On the walk (C4), which S3 follows here: `GridItem::Picture` passes a picture through with its rectangle in
millimetres, since the pixel grid places its pixels, and the SVG and PNG writers refuse it ("not supported yet:
pictures (A9)") until their own slices. `GridItem`, `GridGroup` and `OnGrid` are `PartialEq` only, as a
picture holds numbers that aren't `Eq`.

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

* Paper scene: pictures in a sheet PNG (#5624, W17b S5)

The sheet PNG writer draws the scene's pictures (S3's Item::Picture) where
the scene orders them, between its lines and fills, from a Pictures provider.

- The PNG is now the sheet's pixel grid (PixelGrid::sheet, S1): anchored at
  the sheet's top-left corner and rounded outward, so nothing is clipped.
  210 mm at 300 dpi is 2481 px (it was 2480, rounded to the nearest). A side
  over 65,536 px is refused and the refusal names the limit ("a sheet PNG
  side of ... px at ... dpi is over the limit of 65536 px"); an A0 sheet
  fits up to about 1400 dpi.
- A picture fills exactly its window of that grid, so it registers with the
  lines drawn over the same rectangle. Each band asks the provider for just
  its own rows, as straight-alpha sRGB RGBA, and the bytes are checked: too
  few or too many, or the provider's error, refuse the PNG with the picture's
  source named. paper::NoPictures refuses every picture.
- Pictures are asked for at most MAX_PICTURE_DPI (1200): above it, on the
  grid k = ceil(dpi / 1200) times coarser, whose pixels each fill exactly
  k x k of the sheet's (both grids share the top-left anchor), so a 2400 dpi
  export doesn't ask a renderer for four times the pixels.
- Source-over in sRGB, the picture's alpha times its opacity; transparent
  paper keeps the alpha. In black and white each picture pixel is dithered
  with an 8 x 8 ordered (Bayer) matrix at its place on the sheet, so bands
  don't show; lines stay solid. PngReport says whether anything was
  dithered.
- In the writer a picture is one more shape a band draws, beside A4's
  outlines, fills and hairlines; the walk's picture arm passes pictures
  through, and the SVG writer still refuses them (A9).

Tests: a picture's exact window and its register with a fill over the same
rectangle, also on a sheet 100.8 px high (the grid anchored at its top);
pictures keep their place in the paint order; opacity scales the alpha (on
white and transparent paper, and the picture's own half alpha); above 1200
dpi a coarser picture's pixels are each copied 2 x 2; black and white
dithers half of mid grey and keeps a line over it solid, whatever the band;
a provider's short rows, its error and no provider are refused, and a
picture off the sheet asks for nothing; the bands test draws three pictures
1, 3 or 7 rows at a time, the same bytes and each picture row asked for
once; the sheet sizes on the outward grid and the 65,536 px refusal; the PNG
fuzz with a picture.

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

* Paper scene: pictures in a sheet SVG (#5624, W17b S5)

A picture is an <image> holding a PNG as a data URI, on the picture's own
pixel grid (Picture::grid) at the picture resolution the caller gives,
25 to 1200 dpi: paper_svg(scene, pictures, picture_dpi), the same shape as
paper_png. Only its window over the sheet is embedded and asked for, so it
never costs more pixels than the sheet holds; one off the sheet is left out.
Its x, y, width and height are the grid's own pixel edges put on the
nanometre grid every line is on, so a line on its edge has its number.
Opacity below 1 is written, in millionths as lengths are.

The PNG writer gives the embed its encoder settings (sRGB, the dpi
recorded) and its provider check, now one function each, and streams the
picture a band of rows at a time, refused once it passes the room left
under the file's 64 MiB.

Tests: the 1182 x 591 px grid at 300 dpi and its edges, a line on the
edge, paint order and opacity, the crop and the picture off the sheet, the
bands, and each refusal; the SVG fuzz case gains a picture.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:01:08 -05:00
CamWight 4060069fcd Drawing views: shaded snapshots and their pictures' parts (#5624, W17b S4) (#9798)
* Paper scene: one walk onto a writer's number grid, for every writer (#4890, C4)

The SVG writer kept its own number grid: each coordinate whole nanometres, every joint written once,
a closed path ending exactly on its first point, slivers merged into the next piece and a path's
last piece kept. PDF, PNG and the Drawing workspace's canvas need the same rules, so they move into
`geom::paper::grid`, and the SVG writer only formats what the walk gives it.

- `Grid`: `per_mm` steps a millimetre. `Grid::NANOMETRE` (six decimals of a millimetre) is SVG's.
  `Grid::new` takes 1 to 1e9; a power of two keeps every number exact as a binary float, which a
  writer of 32-bit numbers such as PDF can use.
- `Prepared::on_grid(grid) -> OnGrid`: each group's items as paths of `Piece`s (lines, arcs of at
  most a quarter turn, cubics, parabolas), y up as in the scene, a closed path's closing line left
  to the writer's close, and a report of widths snapped and pieces merged. Slivers are measured in
  the grid's own steps, so a coarser grid merges more.
- The SVG writer flips y on the whole numbers it's given and writes the same bytes as before.
  `SvgReport` is the grid's report.
- Ellipses, hyperbolas and Béziers above degree 3 are still refused (A3b), now by the walk.

DXF stays on its own number rule for now: it writes exact entities (ELLIPSE, SPLINE) and is being
reworked under #5318 to #5321; whether its polylines take a grid is decided there.

Tests: every SVG test unchanged, bytes included; the grid's rounding and its bounds; the A3 border
on the grid (y up, closing line left out, fill loops closed); slivers in the grid's own steps, with
the merge boundary both ways (10 and 11 steps, a 6-8 and a 7-8 step); an arc's quarters with their
radius and turn in steps; a width under half a step made one step; an ellipse refused.

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

* Paper scene: a sheet PNG at a chosen dpi and colour (#4890 A4)

A new writer, io::paper::paper_png, that reads the shared walk on the
nanometre grid, the one the SVG writer uses, so a PNG draws the same joints,
closures and merges. Neither the walk nor the SVG writer changes.

- Curves are flattened within 1/8 px by geom::paper::flatten, which turns
  each walked piece back into its Seg2 (an arc's centre from its ends, radius
  and direction) and takes Seg2::chords' steps. chords is the bound
  Seg2::polyline already used (the chord's bulge for arcs, Wang's bound for
  the rest), moved out of polyline unchanged so both read one rule.
  polyline itself doesn't fit: it floors an arc's tolerance at a millionth of
  its radius and caps a piece at 4096 steps, coarser than asked without a
  word (0.1 mm, 9 px, on a 100 m arc at 2400 dpi). Over 2^20 points a path,
  or 2^24 a sheet, is refused instead. tiny-skia only gets lines: its own
  flattening stops at 64 lines a curve, more than a pixel off on a large arc
  at a high dpi.
- 25 to 2400 dpi (300 by default). 8-bit greyscale (the default), 1-bit
  black and white (no anti-aliasing, every line at least a pixel wide), 24-bit
  colour, or 32-bit colour with transparent paper.
- The file records its dpi (pHYs) and sRGB, so it prints at the sheet's size.
- Each line is stroked once, then the sheet is drawn in bands of 16 MiB of
  pixels streamed into the file, so memory doesn't grow with the sheet. Each
  band also draws a row above and below that isn't written: tiny-skia starts
  an edge a little differently where a clip cuts it (one subsample, 16
  levels, in a few pixels), so no written row sits on a cut and the bytes are
  the same whatever the band.
- A line under a pixel wide (in greyscale and colour) is drawn as tiny-skia's
  hairline, its coverage scaled by its width: its stroked outline would fall
  between the rows the rasteriser samples and vanish.

Tests: the pixel size and pHYs of A4 at 300 and 25 dpi; a line's and a fill's
pixels at 254 dpi (butt ends, the fill's hole); each colour's PNG type, ink
and paper; a hairline between two rows' centres that is light grey in
greyscale and stays one pixel in black and white, and a bar a fifth of a pixel
wide that black and white inks by its centre; a closed outline joined at its
start; a 40 mm circle round to the pixel at 600 dpi; the same bytes drawn 1, 3
or 7 rows at a time or all at once, and again; the refusals; the flattener's
bounds and counts, and its point cap at the exact boundary; the scene-JSON
fuzz through the PNG writer.

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

* Geom: the pixel grid of a paper rectangle (#5624, W17b S1)

One rule for where a picture's pixels lie on paper, shared by the picture renderer and the writers
that place it: anchored at the rectangle's lower-left corner, rounded outward (W = ceil(w / p - 1e-9)
with p = 25.4 / dpi), so the overhang is at the top and the right; rows run top-down. At most
65,536 px a side; nothing caps the total, as pictures are drawn in tiles and strips.

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

* Paper scene: pictures, and a sheet's pixel grid and windows (#5624, W17b S3; #4890 A9)

A picture on a sheet is `Item::Picture`: the rectangle it sits in, the name of its pixels
(`source`, opaque as a group's object id is) and its opacity. The pixels stay out of the scene: a
writer asks a `Pictures` provider for them on the grid it draws on. A PNG at 1200 dpi then gets a
shaded view drawn at 1200 dpi, not one stretched, and a scene stays small, plain data.

The pixel grid gains the two rules a sheet needs, beside the rectangle's own grid:
- `PixelGrid::sheet`: the whole sheet's grid, anchored at its top-left corner, as an image of
  the sheet is drawn.
- `PixelGrid::window`: the columns and rows a rectangle meets; a side on a pixel edge takes no
  sliver beyond it. A picture drawn into its window of the sheet grid is placed by copying whole
  pixels.

`Picture::grid` is a picture's own grid at a dpi, for SVG and PDF to embed. `Scene::validate`
counts a picture as one record. It refuses a rectangle outside the coordinate bound or without
area, and an opacity that isn't above 0 and at most 1. `Aabb2` is read and written as
`{min, max}`.

On the walk (C4), which S3 follows here: `GridItem::Picture` passes a picture through with its rectangle in
millimetres, since the pixel grid places its pixels, and the SVG and PNG writers refuse it ("not supported yet:
pictures (A9)") until their own slices. `GridItem`, `GridGroup` and `OnGrid` are `PartialEq` only, as a
picture holds numbers that aren't `Eq`.

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

* Paper scene: pictures in a sheet PNG (#5624, W17b S5)

The sheet PNG writer draws the scene's pictures (S3's Item::Picture) where
the scene orders them, between its lines and fills, from a Pictures provider.

- The PNG is now the sheet's pixel grid (PixelGrid::sheet, S1): anchored at
  the sheet's top-left corner and rounded outward, so nothing is clipped.
  210 mm at 300 dpi is 2481 px (it was 2480, rounded to the nearest). A side
  over 65,536 px is refused and the refusal names the limit ("a sheet PNG
  side of ... px at ... dpi is over the limit of 65536 px"); an A0 sheet
  fits up to about 1400 dpi.
- A picture fills exactly its window of that grid, so it registers with the
  lines drawn over the same rectangle. Each band asks the provider for just
  its own rows, as straight-alpha sRGB RGBA, and the bytes are checked: too
  few or too many, or the provider's error, refuse the PNG with the picture's
  source named. paper::NoPictures refuses every picture.
- Pictures are asked for at most MAX_PICTURE_DPI (1200): above it, on the
  grid k = ceil(dpi / 1200) times coarser, whose pixels each fill exactly
  k x k of the sheet's (both grids share the top-left anchor), so a 2400 dpi
  export doesn't ask a renderer for four times the pixels.
- Source-over in sRGB, the picture's alpha times its opacity; transparent
  paper keeps the alpha. In black and white each picture pixel is dithered
  with an 8 x 8 ordered (Bayer) matrix at its place on the sheet, so bands
  don't show; lines stay solid. PngReport says whether anything was
  dithered.
- In the writer a picture is one more shape a band draws, beside A4's
  outlines, fills and hairlines; the walk's picture arm passes pictures
  through, and the SVG writer still refuses them (A9).

Tests: a picture's exact window and its register with a fill over the same
rectangle, also on a sheet 100.8 px high (the grid anchored at its top);
pictures keep their place in the paint order; opacity scales the alpha (on
white and transparent paper, and the picture's own half alpha); above 1200
dpi a coarser picture's pixels are each copied 2 x 2; black and white
dithers half of mid grey and keeps a line over it solid, whatever the band;
a provider's short rows, its error and no provider are refused, and a
picture off the sheet asks for nothing; the bands test draws three pictures
1, 3 or 7 rows at a time, the same bytes and each picture row asked for
once; the sheet sizes on the outward grid and the 65,536 px refusal; the PNG
fuzz with a picture.

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

* Paper scene: pictures in a sheet SVG (#5624, W17b S5)

A picture is an <image> holding a PNG as a data URI, on the picture's own
pixel grid (Picture::grid) at the picture resolution the caller gives,
25 to 1200 dpi: paper_svg(scene, pictures, picture_dpi), the same shape as
paper_png. Only its window over the sheet is embedded and asked for, so it
never costs more pixels than the sheet holds; one off the sheet is left out.
Its x, y, width and height are the grid's own pixel edges put on the
nanometre grid every line is on, so a line on its edge has its number.
Opacity below 1 is written, in millionths as lengths are.

The PNG writer gives the embed its encoder settings (sRGB, the dpi
recorded) and its provider check, now one function each, and streams the
picture a band of rows at a time, refused once it passes the room left
under the file's 64 MiB.

Tests: the 1182 x 591 px grid at 300 dpi and its edges, a line on the
edge, paint order and opacity, the crop and the picture off the sheet, the
bands, and each refusal; the SVG fuzz case gains a picture.

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

* Drawing views: shaded snapshots and their pictures' parts (#5624, W17b S4)

drawing.view.snapshot takes `shaded` as a display or reference style and,
when a picture is drawn, says which: picture {pixels, dpi, rect_mm}, the
boundary's pixel grid at the sheet's dpi or 1200, the lower, since lines
may export finer. It renders nothing. A shaded part's own lines are left
out of the reply, as its picture draws it; it still hides what lies behind
it. shaded_visible and shaded_visible_hidden stay refused until the view's
exact lines (W06), and shaded with `scene` (and on a sheet) until the
paper scene draws pictures.

- engine::drawing_picture: the pictures a view draws, bottom first, and how
  each body takes part in each (painted, depth only, left out). The model
  always hides reference parts behind it; reference parts hide the model
  only when reference_hides_model is on; shaded reference parts that don't
  get a picture of their own under the model's, so they never cover it.
- product_scene::extract_where: the scene for the bodies a caller picks;
  extract keeps the browser's eye, a drawing draws every body.

Tests: every style and hide case, reference parts through a subassembly
and a part, the picture's size at 300, 1200 and 2400 dpi, a view with
nothing shaded, the hidden body in the filtered scene, and which lines a
shaded model or shaded reference parts leave.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:01:04 -05:00
CamWight a4c9bc380f Paper scene: text runs and their fonts, as records with their rules (#4890 B1a) (#9512)
`Item::Text` is a run of placed glyphs in one font, with its logical text and the clusters that
tie the two together, and `Scene.fonts` the table of the fonts the runs use (as posted on #4890,
with each glyph's `hmtx` advance). `Scene::validate` holds them to the posted rules. Writers never
parse a font.
- A run names its font by the font's id (a hash of its face's bytes and index), so groups laid
  out apart name a font alike whichever machine's font it is; `Scene::add_fonts` joins their
  tables at assembly, kept in order of id, so the same input gives the same bytes.
- A font's bytes never go through serde: a scene read from JSON is refused until they come from
  the font of the id it names.
- `TextRun::outlines` places the glyph outlines on the sheet, for the writers to fill nonzero.
The SVG writer refuses text by name until B2. `Scene::new` builds a scene without text, so the
next field doesn't touch every caller again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:15:32 -05:00
CamWight f219d9b795 Paper text: a text laid out by sketch's rule, as the scene's runs and their drawing font (#4890 B1b) (#9513)
* Paper scene: text runs and their fonts, as records with their rules (#4890 B1a)

`Item::Text` is a run of placed glyphs in one font, with its logical text and the clusters that
tie the two together, and `Scene.fonts` the table of the fonts the runs use (as posted on #4890,
with each glyph's `hmtx` advance). `Scene::validate` holds them to the posted rules. Writers never
parse a font.
- A run names its font by the font's id (a hash of its face's bytes and index), so groups laid
  out apart name a font alike whichever machine's font it is; `Scene::add_fonts` joins their
  tables at assembly, kept in order of id, so the same input gives the same bytes.
- A font's bytes never go through serde: a scene read from JSON is refused until they come from
  the font of the id it names.
- `TextRun::outlines` places the glyph outlines on the sheet, for the writers to fill nonzero.
The SVG writer refuses text by name until B2. `Scene::new` builds a scene without text, so the
next field doesn't touch every caller again.

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

* Paper text: a text laid out by sketch's rule, as the scene's runs and their drawing font (#4890 B1b)

`paper_text` lays a text out with `TextMetrics`, the rule sketch text draws and measures with, and
returns the paper scene's runs of placed glyphs (B1a) with the font they are set in, holding the
glyphs they place: each one's `hmtx` advance and its outline in font units.
- Drawings are set in bundled fonts only (`DRAWING_FONTS`: Liberation Sans for now). A font is
  admitted once: a static face with a PostScript name, a cap height and a `post` table, its stem
  width measured across its `l`, and its id hashed then. A run names its font by that id.
- A character the font has no glyph for is refused by name; bold is refused; an italic leans as
  sketch text's made-up italic does; text on paper follows no path.
- Lines break a pitch apart and align as sketch text's do. A line with right-to-left characters
  is a run per direction, shaped, with clusters from the shaper; any other is one run, a
  character to a glyph.
`TextMetrics` keeps one layout: it now reports each glyph's cluster and the line's runs, and the
glyph outline walk gives loops of `Seg2`, which sketch text turns into the geometry it drew before.

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

* Paper text: the fonts a text uses, each once (#4890 F0)

`paper_text` returns its runs with the list of fonts they use, each once in order of id (so the
same text gives the same list) and each holding exactly the glyphs its runs place, where it
returned one font. Still one drawing font today; the list is the shape a text set in several
fonts (a Latin, Arabic or CJK line) needs, and `Scene::add_fonts` already takes it as it is.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:15:28 -05:00
CamWight 21888e8000 Drawing views: reference parts in a view's lines and thin on paper (#5866, V5) (#10520)
* Reference parts in a drawing view's lines (V5a, #5866)

A body placed through an occurrence that counts as reference is a reference
part: one rule, Document::reference_occurrences. Its lines say so in replies.
With reference_hides_model off (the default) the model's lines skip reference
parts at the one occlusion test, and reference lines test against everything.
The two roles merge apart, so a reference line never takes a model line's
place on one stretch. Extents come from the model parts when there are any. A
sketch curve is the model's: a reference part hides it only as it hides the model.

One thing the plan didn't name: reference lines stop at the view's boundary
(extents and reference margin) when model parts are there too, which is what
the margin is for; a part much bigger than the model would otherwise run off
the sheet.

Hidden reference lines in a scene are refused until dashed lines (V7b), as the
model's are.

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

* Reference parts on paper: thin, under the model's lines (V5b, #5866)

Saved views and the snapshot's scene alike, 0.25 mm until the drawing styles
slice sets widths by kind of line.

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

* Reference parts: tests the planted faults asked for (#5866 V5)

A reference line against a model line on one stretch, and against another reference part; one part
placed through a reference assembly and on its own; a dimension on a reference part's edge; undoing
and redoing a change of type, set on the occurrence or its component; and a saved view's setting.

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

* A test reads any other paper item as no stroke (#5866 V5)

Naming Fill alone would stop compiling when #7590 adds a kind of item, with
no conflict for git to show.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:51:20 -05:00
CamWight 9b73ec8d9d Display units: every length unit of the expression table shows its own numbers (#4896) (#7809)
A drawing's fixed unit may be any length unit of the expression unit
table (expr::UNITS: um, inch, yd, mil and the rest besides a design's
five), but the display functions knew only the five and showed the
others' measurements as millimetres. unit_scale now reads the same
table, so the two can't drift apart again; the five keep exactly the
scales they had (1 over 10, 1000, 25.4 and 304.8). The inch rule (a
fourth digit when it means something) follows an inch's size, so
`inch` reads as `in` does.

A design's own units are only the five, so nothing it shows changes.
The test that pinned `inch` to millimetres becomes one that checks
every length unit of the table shows its own numbers; angle names
(`deg`, `rad`) and other text keep the millimetre fallback.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:38:22 -04:00
CamWight 0d4739fffb Drawing dimensions: between two cylinders' silhouettes (#4896, W20b) (#9251)
* Drawing dimensions: a hole's or an arc's centre as a point to measure from (#4896, W20c)

A linear dimension's point can be the centre of a circle or an arc, saved as its one edge's name
with `centre`. It is found again as construction geometry finds a round edge's centre
(`construct::as_circle`), in 3D, so any view can measure it; a split edge is ambiguous, and an edge
that is no longer round can't be measured. `drawing.dimension.add` takes `{centre: edge}` wherever
a corner goes, and refuses a straight edge.

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

* Drawing dimensions: a chain of distances along one line (#4896, W20c)

A chain dimension runs through 3 to 64 points in order (corners or hole centres) and reads the
distance from each to the next along one line as the view shows it: horizontal, vertical, along a
direction, or aligned from the first point to the last. The line runs the way the first point to
the last goes, and a point out of order is refused, never moved, so the members add up to the
overall from the first point to the last (`drawing_measure::chain`). It reads projected only (true
lengths of points off one line don't add up) and shows its members' numbers, never a text instead.
`drawing.dimension.add` takes `{chain: {points, direction?}}` and counts the points before looking
any up; `drawing.dimension.edit` changes its direction; inspect lists its members, its value being
the overall.

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

* Drawing dimensions: a whole cylinder's diameter between its silhouettes (#4896, W20b)

A cylinder dimension saves a face by name (ModelFace, as ModelEdge names an edge) and reads Ø
between the cylinder's two silhouettes, which any view but one along its axis shows 2r apart
(`drawing_measure::silhouette_side`). The face, or the pieces it was cut into, must lie on one
cylinder (the kernel's own condition for faces on a common cylinder); every face of the body on
it counts, since the kernel builds a whole cylinder as two half faces; and both silhouettes must
lie on those faces, so a fillet's quarter round is refused. `Document::face_reference` picks a
face by a point on it or by its index; `drawing.dimension.add` takes `{cylinder: {face}}`, edit
refuses its settings, and inspect can list face candidates.

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

* Drawing dimensions: between two cylinders' silhouettes (#4896, W20b)

A dimension between a silhouette of one cylinder and a silhouette of another, as the view shows
them. Each is named by a face of its cylinder and by its side: the one nearer the other cylinder
on the view, or the one farther (SilhouetteSide). So it keeps to them whichever way round the
view shows the cylinders.

- geom: drawing_measure::between_silhouettes takes the two cylinders (Cylinder, now geom's: the
  type doc kept privately), their sides and a direction as a linear dimension takes it (aligned:
  square to the silhouettes). The silhouettes must be parallel on the view and the axes apart
  there. Foreshortened when the axes aren't parallel in the model, or the silhouettes lie at
  different depths.
- doc: DimensionKind::Silhouettes { a, b: Silhouette { face, side }, direction }, read as the
  view shows it. Each silhouette must lie on its cylinder's faces (Lookup::on_cylinder, now shared
  with the cylinder kind). Document::silhouettes_kind makes two silhouettes of one cylinder of
  one body that cylinder's diameter (which takes no direction), else the distance between them.
- engine: drawing.dimension.add takes {silhouettes: {a, b: {face, side}, direction?}};
  drawing.dimension.edit changes its direction only.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 04:49:53 -04:00
CamWight 693cf911f1 Recovery: unsaved drawing work survives a crash (#3890, K9′) (#9289)
* Recovery: a change to a drawing alone is autosaved and recovered (#3890, K9')

A design's drawings are part of its document (K3b), so the autosave already sees a drawing's
change as unsaved work and writes it with the design, and recovery reads it back. This test
holds that: with the model saved, a change to a drawing's properties alone is autosaved, and the
recovered design has the drawing as changed.

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

* Recovery: a drawing that breaks a rule is left out of the autosave and the crash copy, named (#3890)

Reading a design refuses it whole for one drawing that breaks a rule of the record, so an autosave
or a crash report's design holding one never opened again: all the unsaved work was lost. Both
copies now keep the rest of the design (drawing::openable) and name each drawing left out and the
rule: the autosave as its error, the crash report in its notes. A design's save error names the
drawing it couldn't save too (Drawing::label).

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

* Recovery: a copy that must open keeps a design's drawing rules too (#3890)

drawing::openable now leaves out what a read refuses at the design's level as well: the later of
two drawings with one id, and the drawings past MAX_DRAWINGS, each named with its rule. So an
autosave never keeps a stale copy over a drawing problem. The id rule is take_id, which
check_in_design uses too.

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

* Recovery: a drawing too large to save is left out too, and the status line names it (#3890)

The autosave and the crash copy already leave out a drawing that breaks a rule of the record.
A drawing too large to save failed the whole autosave instead; `openable` now leaves it out the
same way, before its id, so the rest of the design is kept. Nothing is skipped silently: the
autosave's error names each drawing left out, and the status line shows it.

Tests: a drawing left out takes no id from a later one; an autosave leaves out one too large to
save; an autosave keeps what is hidden; the status line names a drawing the autosave left out.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:56:45 -04:00
CamWight b21c377296 A lost dimension keeps its last value after the design is reopened (#4896 W19d) (#9764)
Dimension::last_good saves the value the session last read, written only when
there is one, and a save takes it from the session's reads as it takes what is
hidden: no read touches the document, makes an undo step or asks to be saved.
drawing.dimension.inspect answers this session's read first, else the file's.
resolve_dimensions never sees it, so a lost dimension stays lost. A value past
the drawing format's limit is left out of the save, not written into a file that
then can't open.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:59:15 -04:00
CamWight 878bc1b421 A point on a curved edge finds it, between the edge's chords (#4896) (#9765)
An edge given as a point looked along the edge's mesh lines, within a point's tolerance, so a point on a
circle or an arc that fell between two chords found no edge: a radial dimension on most points of a rim
said "no edge at". A point off every chord now takes the edge whose chords are within what its body was
meshed to, and only when no edge passes through the point itself, so a point on a straight edge beside
a curve is still that edge.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:59:11 -04:00
CamWight 562bb379a5 Dimension layout: sizes for paper, and text above the line (#4896, W19c) (#9193)
* Dimension layout: sizes for paper, and text above the line (#4896, W19c)

A dimension's sizes become a parameter, so a drawing can lay its
dimensions out in paper millimetres with the same code as a sketch:
DimSizes { arrow, gap, overshoot, offset, above } and
dim_layout_sized. dim_layout and dim_layout_reaching keep their
signatures and use DimSizes::screen(px), today's 9, 3, 4 and 22 px;
the other default text places stay the same fractions of the offset
(18, 48 and 20 to 22), and default_text takes its 22 from the same
place. A size that isn't a finite number above 0 (or is beyond MAX_COORD,
the largest length a sketch takes) falls back to its 1 px value, as a
bad px does today.

`above` puts a linear dimension's text above an unbroken dimension
line (ISO 129-1), running along the line and reading from the bottom
or the right; DimLayout::text_dir gives its direction (level for text
in the line). The arrows go inside or out by the same test, with the
text's length along the line; a leader to text outside runs on under
it.

On main, #7056 moved the four default places into
default_text_offset(frame, px). This leaves those lines as they are,
so when main next comes into drawings the layout's arms call it with
this px (the offset's 22nd), and lib.rs's export line takes both lists.

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

* Format the dimension layout's re-exports (#4896, W19c)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:59:49 -04:00
bherbruck 1a47ac13de Merge pull request #9467 from tangency-labs/sync/main-into-drawings-1030
Sync main into drawings (Oct 30)
2026-10-30 10:29:35 -04:00
bherbruck 1c957c105d Translate the drawings' new messages into the complete catalogs (ar, he, vi)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:15:28 -04:00
CamWight ec527a32ab geom: arrowheads and leaders on paper (#4890 D1a) (#9466)
`paper::annotate` places the marks that point at things on a sheet as plain
strokes and fills in sheet millimetres: open, closed and filled arrowheads,
the oblique stroke and the dot, and leaders (points, a landing line in the
reading direction, a terminator at the first point). Every size is the
caller's; ISO 128-2's dot and landing ratios and ISO 129-1's 15° to 90°
arrowhead angle are constants beside them. A closed or filled arrowhead stops
its line at the base. Dimension layout stays W19c's (#4896) and the datum
triangle the datum symbol's (#7257).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:59:02 -04:00
bherbruck d01de87552 Merge main into drawings (Oct 30)
Brings the 1,270 commits on main since the drawings branch last met it,
including the first drawings landing (#8249), into drawings.

Merged with the drawings snapshot that #8249 landed (ebafe0748) as the
merge base, so only the drawings work after that landing is replayed on
top of main. Main's structure wins; drawings' additions are kept and
adapted to main's APIs (metadata Owner/KEYS table, checked! without a
contract argument, Snapshot visibility, crash journal outcome, the
part-properties normalisation, model_body_components, io helpers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 09:54:08 -04:00
github-actions[bot] ab67126eec Regenerate generated files and fold pending files [skip ci] 2026-10-30 12:31:20 +00:00
Nathan Dane 3b8cbc4e22 Viewport: surface analysis status items and legend keys (#8547) (#8556) 2026-10-30 12:12:19 +00:00
Frank f59f6698f1 Ribbon drop-downs as a grid of large icons, as an option (#4739) (#5012)
* Ribbon drop-downs as a grid of large icons, as an option (#4739)

Preferences > Interface gets two settings, both off by default:

- Menus: List | Large icons. With Large icons a panel's drop-down shows its
  commands as rows of up to five icons at 32 px (twice the list's), keeping
  the list's group headings and order. The name, shortcut and help are in
  the tooltip, as on a ribbon button. Arrow keys move across and between
  rows; a click starts the command; right-click and drag pin it as before.
  Resting on an icon for two seconds arms a pin in its corner; a quicker
  click anywhere starts the command, so nothing is pinned by accident. A
  pinned command shows its pin.
- "Leave pinned commands out of the drop-down menus": a drop-down (list or
  grid) leaves out the commands the ribbon shows as buttons; a panel too
  narrow for its buttons lists them again, and a menu is never left empty.

No icon artwork or colours change. Sketch > Create keeps its family list.

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

* Sketch > Create in the large-icon grid: every variant under its family (#4739)

With Menus: Large icons, Sketch > Create showed its family sub-menus as
plain text rows (Line, Rectangle, Circle, ...), unlike every other menu. It
now shows every variant as a large icon under its family's heading, then the
other tools under "More". The list keeps its family sub-menus.

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

* Large-icon menus: groups side by side, no footer (#4739)

A menu of two or more headed groups (Sketch > Create's families, Model >
Modify's Edges, Faces, Bodies, Specialise) puts the groups side by side, each
heading over a column of its icons, at most two across. Menus without groups,
or one too wide for the window, keep rows of up to five. The pinning note
under the large-icon menus goes: the tooltips and right-click menus say it.

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

* Large-icon menus: a separator line between groups side by side (#4739)

As between the ribbon's sections: a 1 px line in the theme's border colour.

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

* Large-icon menus: at most three rows, growing to the right (#4739)

A menu fills five columns, more when that would take over three rows (Model >
Create: 7 x 3); in grouped menus a group too big for three rows gets more
columns. Only a window too narrow for that wraps into more rows. A test checks
every panel's menu of every tab.

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

* Large-icon menus: group headings centred over their icons (#4739)

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

* Drop-down group headings: smaller than the drop-down's header, not in capitals (#4739)

Edges, Faces, Bodies (and Sketch > Create's families) were drawn in capitals
at font_small, which reads larger than the drop-down's own header
(font_caption). They now use font_micro, written as named, in the list and the
large-icon grid. Review feedback on #5012.

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

* Large-icon menus: start a command from the grid at its icon, as main's rows do (#4739)

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

* Ribbon icon grid strings: a pending locale file, with ar, he and vi (#4739)

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

* Large-icon menus: commands in no group stand apart, so a left-out group doesn't move them (#4739)

With Convert to Sheet Metal pinned and left out of Model > Modify, its
Specialise group went and Delete, in no group, moved under Bodies.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:59:08 +00:00
CamWight fefca6bfa0 Drawing export: a sheet of the open drawing as SVG, from its scene (#4890 A7) (#9137)
* Drawing sheets on paper: the drawing border, grid and marks (#4890 A6, #3875)

SheetLayout::paper gives what a sheet's drawing border draws as paper
groups of kind drawing_border, in order, each left out when empty:

- the drawing border, one closed stroke drawing_border_line_mm wide
  (0.7 mm in the built-in profile, ISO 5457);
- the grid: the grid reference border, closed, and a mark at each edge
  between two fields from the drawing border out to it, on all four
  sides, GRID_LINE_MM (0.35 mm, ISO 5457's grid line width as the
  drawings study's section 11.3 gives it) wide; none without fields or
  with the grid reference border on the drawing border;
- the centring marks, as the layout places them, in the drawing
  border's width, as the profile says;
- the trimming marks, each rectangle filled.

The grid's labels are text and come once the paper scene has text
(#4813). Only the function, its constant and its tests are added.

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

* Drawing sheets on paper: one sheet's scene, its border then its views by id (#4890 W10, #3890 W15)

`drawing_scene::build` draws one sheet of a drawing as one paper scene, the
one the sheet page and every export will draw, and `drawing.scene {sheet}`
answers it (in_drawing, noundo, doc_only, typed contract and output schema).
In paint order: the sheet's drawing border groups as `SheetLayout::paper`
gives them (object: the sheet), then each view on the sheet by id (object:
the view), its visible lines as `paper_group` makes them.

- A view's lines are worked out centred on the view and then moved to its
  centre, exactly (each point plus the centre), so the same lines on the same
  centre are the same bytes. They match the snapshot's within rounding.
- The shown set is worked out once per sheet. No model is evaluated.
- A view with nothing to measure is a placeholder, and one that can't be
  drawn yet (shaded, or hidden lines) fails with the snapshot's reason. Each
  draws nothing, and the sheet's other views are still drawn.
- A view is placed only while the sheet stays within the paper scene's
  100,000 records. One that would pass the limit fails and says so, and later
  views that fit are still drawn. Each view's lines are placed as they are
  drawn, so at most one view's lines are held beside the scene.
- A view above 100:1 takes a coarser chord rather than asking the mesh for
  chords below its floor. A saved view has no chord setting to give.

One rule, one place:
- the snapshot's two style refusals are `drawing_view::not_drawable`;
- a group's record count is `paper::Group::records`, which `Scene::validate`
  now counts with;
- the stats reply is `From<Stats>`;
- both paper scene schemas come from `paper_scene_schema`.

Tests: the border then the views by id, byte for byte against the layout,
with hand-worked outlines and lengths; the command's reply and its schema; the
SVG's group order; each view against the snapshot, and one of many lines (a
block with eight holes, from above); an empty sheet; a failed and a
placeholder view; no evaluation; refusals; the record limit at exactly 100,000
and one past it; a 200:1 view.

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

* Drawing sheets on paper: the provider the sheet page draws through (#4890 W10, #3890 W15)

The sheet page asks for a sheet every frame, so it draws through a provider
that keeps each view's lines between frames, instead of through `build`.

- `drawing_scene::extract(session, sheet)` returns the last scene at once,
  with each view as it was last drawn and the ones out of date. Nothing is
  drawn there. When the drawing, the world state and the views drawn are all
  as they were, it returns the same answer without looking at the sheet.
- `drawing_scene::step(session)` draws the first out-of-date view of the
  sheet last extracted and says whether more wait: a short piece of work for
  the UI's tick, one view's projection.
- `generation` comes from one counter for every session. It goes up only
  when a sheet's scene is assembled again, so a page never sees one number for
  two scenes, even across closing and opening its drawing.

The cache is the session's view state, beside the record: never saved, never
an undo step. `Session::execute` drops it when a command closes the drawing or
opens another. One entry per view, keyed by the world state it was drawn from
(held in the entry and compared with `Arc::ptr_eq`) and the view as it resolves.
A drawing-only edit keeps the world state, so a move, and its undo, project
nothing: the same centred lines are moved to the new centre. The shown set is
worked out once per world state. Views no longer in the drawing are forgotten
when a scene is assembled again.

Tests:
- a new cache gives the border with both views out of date;
- each step draws one view, in id order, until the scene is `build`'s, byte for
  byte;
- the generation stays put while nothing changes;
- a move and its undo project nothing, and the views move by exactly the move;
- after a model change (a checkpoint restored) the last scene shows, and a view
  listed as up to date always draws as `build` does;
- closing drops the cache, and the scene after reopening has a new generation;
- seeded mixes of moves, setting edits, undos, redos, extracts and steps
  always end at `build`'s scene.

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

* Drawing export: a sheet of the open drawing as SVG, from its scene (#4890 A7)

drawing.export {sheet, format?, path?} writes one sheet of the open
drawing from drawing_scene::build's scene, the scene the sheet page
draws, so a file shows what the page does: its drawing border, then
each view's visible lines, through the SVG writer. With a path (a .svg
file, checked by vfs::check before and again just before writing, as
the view DXF is) the file is written atomically; without one the reply
holds the SVG. The reply gives its size, what the writer snapped or
merged, and each view's status as drawing.scene reports it.

A sheet with a view that failed isn't written: the refusal names each
such view with why. A placeholder view (nothing to measure) draws
nothing and is exported all the same. PNG and DXF are "not supported
yet": they come with the sheet PNG writer (#4890 A4) and the shared DXF
writer (#5319). No undo step; no model is evaluated.

One rule, one place: the scene's sheet look-up is
drawing_scene::sheet_id, and a view's reply entry is
From<ViewState> for SheetSceneView, both used by drawing.scene and
drawing.export. The scene tests' drawing fixture is open to the crate's
tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 01:20:10 -04:00
CamWight 6a2bdbf144 Bill of materials: virtual components and a typed quantity (#3874, P6) (#9226)
`bom_quantity` is a part property of any component: what one placement of it means, an expression
evaluated with the design's parameters (pieces without a unit, else a length). A row's `quantity`
is what its placements add up to, with `quantity_unit`; pieces beside a length, or a quantity that
no longer evaluates, give none and say why. A quantity is a parameter user: a rename follows it, and
a parameter it uses can't be deleted. The CSV gains a `quantity` column at the end.

`virtual` marks a component with no geometry (grease, paint, a label): listed with its quantity,
weighing nothing. It can't be the active component, and one check after each undoable outermost
command refuses any change that puts a body, a sketch or a placed component into one, whichever
command made it. A file a damaged virtual component already fills opens, and other changes go on.

Mass, volume and area quantities wait for the expression units of #4811.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 01:15:51 -04:00
CamWight 413c145c56 Bill of materials: stable item numbers, saved with the design (#3874) (#7779)
* Bill of materials types on one occurrence (#3874)

An occurrence can count in the bill of materials as another type than its
component, for that occurrence only: `bom_type` on the occurrence, set with
occurrence.bom_type (one undo step; it changes no geometry, so nothing is
evaluated). An occurrence without it counts as its component, so older
designs need no step; the format change is one pending file
(occurrence-bom-type.rs).

assembly.bom counts each placement by its own type: a purchased placement of
a kit part is one row without the kit's contents, a phantom placement lists
what it holds where it sits, and one component placed as two types gives a
row for each. Paste New keeps the types; Paste Instance counts as the
component. The compact list and the locales (en, de, ja, ko, zh) carry it.

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

* Bill of materials rows: material, and mass when asked (#3874)

Every row of assembly.bom (and every part folded into a row) names the
physical materials of its definitions' own bodies, each once, with "; "
between several. An unassigned body's is Default. A subassembly or an
empty component has none of its own.

With mass: true each row also carries mass_g, what one of its parts
weighs as the row stands for it, so the rows of any list add up to the
assembly once:
- at the top level, all it holds;
- in parts only, its own bodies, because what it holds is listed beside
  it; but a folded part's row is the kit (all it holds), a purchased part
  is all it holds, and an inseparable one all but its purchased parts;
- a reference placement adds nothing, nor does anything inside it.
It is null when nothing in the part has a body, a body can't be
measured or measures a negative volume (inside out, such as an open mesh
facing the origin), or the definitions sharing the row's part number
weigh differently.

Measuring runs the kernel, so it is asked for: the Bill of Materials
window re-runs assembly.bom after every change and doesn't ask (0.2 ms
without mass, 135 ms with it on a bolted bracket). Placements are rigid,
so each definition is weighed once however often it is placed, and each
body is measured once with measure_mass (the same volume as measure,
without the topology counts' tessellation).

A body's material and its density now follow one rule:
Document::body_material (new, beside Document::density) is the material
its mass is worked out with, named as the library or the design spells
it: the assigned one when it is known and has a usable density, else
DEFAULT_MATERIAL (new, beside DEFAULT_DENSITY). Document::density is
that material's density, with the same values as before. So a damaged
file's unknown or broken material reads as Default, as it weighs, and a
hand-edited "steel" reads as Steel. The bill of materials uses both; the
other places that read a body's material or spell "Default"
(component.properties, inspect and others) move to them in a separate
cleanup PR for #4783, which brings the same two functions to main.

Document::body_owners returns each body's owner in order, so the rows
find their bodies in one pass. It no longer repeats the owner rule:
model_body_component, legacy_body_component and body_owners go through
the rule written once, given how to find a feature's component (the
timeline for one body; body_owners still builds its map once). The CSV
columns are unchanged.

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

* Physical properties: a reference placement adds nothing (#3874)

component.properties and occurrence.properties now leave out a placement
that counts as a reference in the bill of materials (its occurrence's
bom_type, else its component's: a fixture shown for context), and
everything inside it: no body entry, mass, volume, area, centre of mass
or bounding box, and it isn't in placement_count. That is the bill of
materials' rule, so a reference part is in neither the parts list nor
the assembly's mass, and every bill of materials list still adds up to
the assembly's Properties. The placement asked for counts whatever its
type: a reference fixture's own Properties still measure it and what it
holds, as a component's own bill of materials lists what it holds.

Only the totals change. The placements walk that face picking, sketches
on placed faces and the occurrence path lookup use stays complete, so a
reference fixture's faces can still be picked.

The type an occurrence counts as is now written once:
Document::occurrence_bom_types (new, beside BomType) gives each
occurrence's type in their order, looking each component up once. The
bill of materials, occurrence.bom_type's reply and the physical
properties use it, in place of the two copies in the bill of materials.

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

* Bill of materials: one module of typed rows in doc (#3874)

The bill of materials now lives in solidcraft_doc::bom as the typed rows
of a bill of materials view. assembly.bom's JSON and CSV and the Bill of
Materials window present those rows; parts lists, item numbers and
balloons are to read them too.

- list(tree, request) 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 type it counts as, the view and the component's
  shape. "An inseparable part's purchased parts are listed beside it" is
  written there once, and the mass walk reads it.
- A row carries its identity (RowKey: the rows it is listed under and
  its lowest member, a component and the type it counts as), its item,
  count, each, the placements it counts by occurrence path, what it
  stands for (Holds), its part properties, materials, mass and children.
- Mass is a Mass: not asked, grams, or unknown with the reason (nothing
  to weigh, unmeasurable, the definitions differ). Each definition is
  weighed once for each kind of row and kept in the Tree. The assembly's
  total is weighed from the design the same way, not added up from rows,
  so it is known even when a row's definitions weigh differently.
- Bom::row_of(tree, path) finds the row a placement is counted in (a nut
  inside a purchased bolt is in the bolt's), or says why there is none:
  no such placement, a reference, or no row of its own.
- rows_deep() lists each row followed by its children (1, 1.1, 1.2, 2);
  Request::default() is assembly.bom {}; View and Group carry the
  commands' words.

What changes, beside that the output is otherwise the same:
- One placement budget (100,000) and one depth limit (64 levels) cover
  the whole list. A folded part's contents used to be walked under a
  budget of their own, so a design past the limit was listed with
  group_children; it is refused now, as it already was without it.
- Kits are compared by what they hold (part, type and count), not as
  text: a part without a part number whose id is 12 no longer reads as a
  part numbered "#12", nor a part numbered "P×1\nQ" as a P and a Q. Kits
  holding the same parts through different definitions (two bolt
  definitions, each with its own nut definition of one part number) are
  one row, and its children count every bolt's placements, where they
  showed the first bolt's definitions only.
- A folded part's row always stands for all it holds. It used to stand
  for its own bodies when nothing inside it was listed, which differs
  only for a phantom with bodies of its own inside it.
- An inseparable part's row in parts only names the material of what it
  stands for (its steel blocks read Steel) where it was blank, and a
  subassembly whose parts share one material names it at the top level.
- A damaged file's component placed inside itself is refused at the first
  level too, since naming a row's material walks inside it, as weighing
  it already did.
- A row's body count saturates, so a compact assembly with more placed
  bodies than a count holds (a block placed 2^64 times) is weighed
  instead of overflowing.

Materials follow Cameron's rule for a row that stands for an assembly
(C3, 2026-10-23): a part's row lists its own bodies' materials, as before;
a row that weighs what is placed inside it (a subassembly at the top level,
a purchased or inseparable part with parts inside it, a folded part) names
a material only when everything it stands for shares it, else none. A frame
of steel and aluminium parts reads blank, and a bought bearing of steel
parts reads steel. One walk (adding) decides what is placed inside a row's
part for both its mass and its materials, and each definition's materials
are read once.

Components are looked up by id once per list; they were searched for
each row and each kit. The window reads the rows directly instead of
running assembly.bom after every change, so it no longer adds an entry
to the crash journal each time it refreshes. The engine keeps the
command, its parameters and the files.

Tests check rows. The module's own: an empty assembly, one part, a row
named by its lowest member through part-number edits, one part number
of two types, a phantom in a phantom, a reference at the root and its
own list, the 64-level limit, the budget hit exactly with and without
folding, kits told apart by what they hold and the types they hold it as, a folded part's contents not
folded again, row_of for every type, every placement in one row or
none, a damaged file's missing component and cycle, the mass agreement
rule (moved from the engine with row_mass) and what adds to a row. In
the engine: the material rule for every kind of row, the JSON against the
rows for every option, why a mass is
unknown, the total against component.properties in every view, what a
row stands for and its placements, and the overflow. The assembly.bom
tests from #1888 on pass unchanged.

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

* Bill of materials: structured, all levels (#3874)

P4: `assembly.bom {levels: "all"}` lists the assembly's direct contents, each subassembly a row with
its own rows listed under it, to any depth (items 1, 1.1, 1.1.1), each child counted `each` per one
of its parent and `count` in all.

- `doc::bom::View::AllLevels` goes through the one rule (`classify`) the other views use: a
  normal subassembly is a row holding what it holds, listed under it as all levels again; a
  phantom has no row and what it holds joins the level it sits in, merged with the same parts
  there; a purchased part is a row with nothing under it unless `show_purchased_contents`
  (Request::purchased_contents) asks for its contents; an inseparable part is a row with nothing
  under it; a reference counts nowhere.
- The rows under a row are the folded-part rows already built (kits), so subassemblies with one
  part number and different contents are different rows, and no second nested builder is written.
  All levels gathers every level as it gathers the first; a folded part's contents are still
  gathered by part number in the other views.
- The JSON and CSV already nest to any depth; `show_purchased_contents` is echoed with the other
  options. Each row's mass still adds up to the assembly at the top.

Tests, worked by hand: four bolts and a two-bolt subassembly placed twice (8 bolts in parts only;
in all levels the subassembly twice with 2 bolts each, 4 in all, beside the top's 4); three levels
deep (2 x 3 x 2: counts 2, 6, 12, items 1, 1.1, 1.1.1, the CSV in that order); each type (a
phantom's bolt merged into its subassembly's, purchased with and without its contents,
inseparable, reference); two subassemblies of one part number with different contents as two
rows; `levels: "deep"`, a flag that isn't one, and an assembly over 100,000 placements refused.
D's test that a folded part's contents are listed as parts only lists them now names the two views
it holds for, and checks all levels' nested rows (the subassembly inside the folded part a row with
its own rows).

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

* Bill of materials: parts with one part number are one row only when they hold the same to any depth (#3874)

The kit compare looked one level down: two parts with one part number whose contents differed two
levels below merged into one row, and the children that only one of them held came out at each 0.

Each placement now has a kit, a number interned from what it holds (each child's part, type and
own kit), built in one pass from the last entry back: an entry's children come after it in the
walk, so their kits are known first. Rows are told apart by that number.

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

* Bill of materials: a phantom's own bodies are a row of their own (#3874)

A phantom had no row, and its own bodies were weighed in the assembly's total but in no row, so
the rows didn't add up. Its own bodies, when it has any, are now a row of the phantom's type that
stands for them alone, as a subassembly's own bodies are in parts only; what it holds still counts
where it is placed. Inside an inseparable part in parts only, nothing changes: the part's row
weighs them.

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

* Bill of materials: the assembly's total mass in assembly.bom (#3874)

`list` weighed the assembly as a whole (from the design, not added up from rows) and the command
dropped it. `assembly.bom` now gives it as `total_mass_g`, and so does a JSON file it writes, only
when `mass: true` (null when it isn't known).

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

* Bill of materials: all levels as JSON lists its rows one after another (#3874)

All levels nested each level's rows in the row above it: two JSON containers a level, so the
64 levels a list may hold came to 129, past the 128 that serde_json (and so the MCP server and
the control channel) reads. In all levels `assembly.bom` now gives the rows one after another,
each followed by the rows under it (items 1, 1.1, 1.1.1), as its CSV does; a row listed under
another still reads its item as text and gives `each`. A folded part's contents, one level, stay
inside its row as `children`.

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

* Bill of materials: a row whose definitions weigh something and nothing differs (#3874)

A row gathering a definition with bodies and one with none (one part number on a steel block and
on an empty assembly) said there was nothing to weigh. Their masses differ, so it says so; only a
row where none of them has a body has nothing to weigh.

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

* Bill of materials: definitions agree on a mass within the measurement accuracy (#3874)

A row's definitions had to weigh the same to 1e-9, but the kernel measures a volume from
tessellations, and one part modelled another way is tessellated differently: a pin made as a
cylinder and as a revolved rectangle measured 1.05e-5 apart, so it read as two masses. The bill of
materials now has its own `bom::MEASURE_ACCURACY` (1e-4: ten times that gap, with its doc on what
it follows: the kernel's `measure` gives one solid's volume to about that, relative, however the
solid was modelled), and a row's definitions agree within it. A pin 0.1% wider (0.2% heavier) still
differs. The kernel is unchanged: its work is paused while it is being replaced.

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

* Bill of materials guide: all levels, purchased contents and this round's changes (#3874)

The guide had no `levels: "all"` or `show_purchased_contents`. It now has both, with how all
levels tells subassemblies apart and lists its rows as JSON, a phantom's own bodies as a row, the
assembly's `total_mass_g`, and definitions that agree on a mass within the measurement.

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

* Bill of materials: materials are bodies', as the code says (#3874)

The rule stays as built (a row that stands for an assembly names a material only when everything
in it shares one), but its comment and the guide said a material set on a component is set on
all its bodies. `material.assign` takes bodies only: a component has no material of its own yet.
The module notes now say so, and where component materials will join the rule.

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

* Bill of materials: a body's volume measured once with the evaluated model (#3874)

`assembly.bom` measured each definition's bodies anew for every list, and `component.properties`
measured each placed body again. A body's measure depends on nothing but the body, so the evaluated
model keeps its volume (`ModelBody::volume`, shared by clones, as its outline box is), and both
commands weigh that one measurement: the same grams to the bit. A body offset scales it by the size
of its determinant (`mat_volume_scale`) instead of copying the B-rep to measure it. The physical
properties still measure the placed body for its area, centre, box and counts.

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

* Bill of materials: row keys, what rows stand for, members and row_of stay in doc (#3874)

Nothing outside `doc` reads them yet: `RowKey`, `Unlisted`, `Holds`, a row's `key`, `stands_for`
and `members`, and `Bom::row_of` are crate-private until item numbers and balloons need them, so
they can still change without breaking another crate. The engine test that read them is covered
by doc's own (`row_of_finds_the_row_a_placement_is_counted_in`, `every_placement_is_in_one_row_or_none`).
`Unknown` stays public: `Mass::Unknown` carries it, and a public enum can't carry a private type.

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

* Assemblies: one fallible placement walk for totals that cover the whole assembly (#3874)

The physical properties walked the placements twice in their own code, with the 64 levels and
100,000 placements written as literals, and the bill of materials gave the same refusal in words
of its own. `Document::placements_below` is that walk once, from any placement, refusing what is
past `MAX_PLACEMENT_DEPTH` or `MAX_PLACEMENTS` (never walking it in part), and both use its
refusal: "the assembly is deeper than 64 levels or has more than 100000 placements". The physical
properties now allow 100,000 placements below the start, as the bill of materials does (they
stopped at 99,999).

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

* Bill of materials: the same rows for less work (#3874)

- Part numbers are borrowed from their definitions (`Part<'a>::Number(&'a str)`) instead of copied
  for every placement grouped and every kit.
- A row's members, the placements' paths, are built only when the list is asked for them
  (`list_with`, for item numbers, balloons and `row_of`): the walk then keeps paths and moves each
  into its one row instead of copying it. A list made for its rows alone builds no paths at all.
- `row_of` reads an index of member paths, built once with the list, instead of scanning every
  row's members for each placement asked about.

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

* occurrence.bom_type: input and output schemas (#3874, #4035)

`OccurrenceBomTypeParams` takes the occurrence (an id or a name), its path when given and the
type (one of the five, `same_as_component` or null), each read by the handler's own reader: the
path through `cmd::path_ids`, split out of `occurrence_path`, the type through
`cmd::occurrence_bom_type`, split out of the handler. The handler builds its reply from
`OccurrenceBomTypeResult` ({occurrence, bom_type, effective, changed}).

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

* Bill of materials tests: all levels presented at every depth, and its caps (#3874)

- `assembly_bom_presents_the_typed_rows` now covers all levels (with mass, purchased contents and
  by definition) and the total mass, on a three-level design with a purchased gearbox holding a
  gear, comparing every field of every row down to 1.1.1.1.
- All levels lists 100,000 placements and 64 levels, and refuses 100,001 and 65.

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

* Bill of materials: static materials, typed in for a component (#3874)

A value the bill of materials works out can now be typed in by hand for a component (a static
value, Cameron's choice of word). Material comes first:
`component.metadata.set {component, material}` takes any text up to MAX_TEXT_BYTES, and
`component.metadata` reads it back as `material`. It is shown wherever the component is listed,
at every level and in every view, over the materials worked out from its own bodies or an
assembly's blank, and the row carries `static: ["material"]`. Clearing it (empty text or null)
brings the worked-out material back.

It is kept the way `bom_type` is: a part property in the component's metadata (`material`), so
there is no format change and older releases keep it. `PART_ONLY` names both keys in one place:
Design Properties doesn't list them and `document.metadata.set` refuses them. Another tool's
non-text value is kept and reads as no static material.

The bill of materials says one thing about the part everywhere: a static material also stands for
the part in the one material an assembly around it shares (two parts typed in as S355 make their
frame read S355; S355 beside plain steel reads blank). A phantom's row is its own bodies, so its
static material names those only, and what it holds keeps its own. A row gathering several
definitions takes the first one's that has a static material, as its description does. It only
names: mass still comes from the bodies.

The CSV gets a `material` column at the end, so the other columns keep their places: what the row
shows, the static material or the worked-out ones joined with "; ".

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

* Bill of materials: stable item numbers, saved with the design (#3874, P5a)

A numbered parts list keeps each row's item number under the row's key in
`assembly.item_numbers`, so a row keeps its number when others are added,
removed or reordered. New rows take the next number after the highest at
their level; Renumber restarts a view in list or item order and leaves
locked rows alone; duplicates are marked; numbers change no geometry and
are part properties in the undo history.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:29:25 -04:00
CamWight b708170028 Drawing W07: a view's lines, on paper, for the occurrences it shows (#5866) (#8970)
* View DXF: its lines come from one module both view commands share (#5866, W07 V1)

The view DXF (file.export_view_dxf) finds its lines in four files private to the command: the
export meshes, the projection, the hidden-line scene and the progress stages. Drawing views
(W07's drawing.view) need the same lines, so the four move to engine/src/mesh_lines, with
three changes that let a caller other than the DXF use them:
- prepare takes the bodies to draw from its caller (the DXF passes the shown ones), and
  names the caller's command in its errors;
- the lines are seen from a View (an origin, a right/up/back basis and, for perspective, the
  eye's distance) instead of a viewport Camera; the DXF builds one from its camera, with the
  same arithmetic as before;
- a sketch's polyline (Part) belongs to the module, so the sketch code no longer defines what
  the scene draws.

The DXF writing stays in cmd/view_dxf.rs, with connect (the dashed-line joining), and the stage
names and cancellation text stay the DXF's. Nothing else changes: the module is the DXF's own
code, moved, and the DXF writes the same bytes as before for every case checked (see the PR).

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

* View DXF: the sketch runs go once the lines are made, and a box's outline pinned (#5866, review)

From the review of #6482:
- The sketch runs (up to 4,000,000 points) are dropped once the lines are made, as they were
  before the move, instead of living through the DXF writing.
- `mesh_lines::prepare`'s doc says which errors name the command: those about the placements.
  A body with no exportable surface, the memory budget and cancellation come back as the meshing
  stage's own.
- A new test pins the lines themselves by hand: a plain 40 x 30 x 10 box seen from the front is
  its 40 x 10 outline. Every line lies along a side, they add up to the 100 mm perimeter, and
  nothing is hidden.
- The shared-geometry test hides the definition's first placement: the other two are still
  drawn from the one shared mesh.

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

* Drawing views: the bodies a view shows, worked out once per request (#5866, W07 V3)

A view's extents and, next, its lines read one list: each body the design places, with the
occurrences that place it and its definition's body name, in the world state's order. Choosing
occurrences and hiding parts (V4) and the reference split (V5) become filters on it.

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

* Drawing views: a view's lines from the mesh projection, each named by what drew it (#5866, W07 V3)

drawing.view.snapshot now returns its lines: every shown body's edges and silhouettes from the
view DXF's mesh projection, seen orthographically on the view's basis, in pieces of one visibility
on the sheet. Each line names its occurrence path, its body and its source (an edge, as model.edges
numbers it, with whether it is a tangent edge, or a silhouette across a face). They are marked
approximate and sampled (Cameron's choice, 2026-10-27): the exact lines (W06) take over per body
after the kernel swap (V8).

- display_style visible or visible_hidden; tangent_edges hide or show; chord_mm, the paper chord
  the meshes are asked for (0.001 to 1, 0.01 by default). The model chord is chord_mm over the
  scale: the view DXF's 1 mm cap doesn't apply, so a view below 1:100 keeps its chord on paper;
  its 0.0001 mm floor does, and a chord under it is refused with the chord_mm that would do.
- max_error_mm is a check, never a setting (the accuracy rule on #5866): refused while any piece
  is sampled, with the count of pieces short of it.
- The three view DXF changes moved here from V1: each segment keeps its owner (its placement and
  its edge or silhouette face); a stretch two sources share belongs to the nearest visible one,
  else the nearest hidden one (level ones in owner order), ends where that changes, and counts as
  covered; the triangles keep their faces for that. The view DXF asks for neither, so it merges and
  writes as before. (Nearest, not first: a hole seen end on has its bottom rim under its top one,
  and by owner order the bottom rim took the stretches it shows through, cutting the top rim up.)
- A mesh side between two faces every edge between which is drawn is no silhouette: it lies on one
  of those edges, already drawn from its own curve. (Seen end on, a hole's wall faces neither way,
  and the side it shares with the top face drew stray chords just inside the rim.) The triangles
  keep their faces for it in both commands (4 bytes each, in the memory budget); without them the
  view DXF drew a hole's rim twice, and a view DXF test now finds every line drawn once.
- A triangle seen edge on, its normal at most 1e-3 (cosine) toward the view, faces away whatever
  sign the mesh's own error gives it, so no silhouette lies between two of them. (Seen end on, a
  bend's surface drew its rows as chords beside its arcs: a 60° bend's outer arc 1.76 times over.
  A view DXF test now finds each of its arcs drawn once.)
- Mesh positions within the tolerance weld: a position takes the key of the first one within it,
  in its own rounding cell or a neighbouring one. (The faces round a hole's rim are meshed apart,
  their copies of a rim vertex up to about 1e-7 mm apart, and copies either side of a cell
  boundary never met: each copy's side was a boundary, drawn as a silhouette.) The view DXF takes
  these three rules too; they change its lines only where a side lies along a drawn edge, a
  triangle is seen edge on or a weld was missed. The sample designs' view DXFs lose lines, and
  each line they gain lies within 0.0011 mm of an old one.
- One source's stretches join into runs within twice the tolerance the lines were merged at: the
  merge drops spans up to that long, so a stretch can stop that short of a corner on either side.
- The tangent test is a stadium of cubic segments: the kernel marks a join smooth only between
  faces swept from cubic segments, and one between two pieces of one surface is a seam, never
  drawn; a fillet's round keeps its edges, as the viewport draws them.

compile fix by the personal PC

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

* Drawing views: a circle facing the view is drawn as its exact arc (#5866, W07 V3)

An edge that is a circle, or part of one, in a plane parallel to the view
is drawn as its exact arc from Body::planar_edges instead of the mesh's
chords. Where the mesh projection says its visibility changes, the arc is
cut there: a piece that runs along all of the edge is analytic, a cut
piece keeps the mesh's sampled accuracy; its ends may stop within the
stretches' join tolerance of the edge's own, as the merge leaves them.
planar_edges is asked once per shared mesh and placement turn, for the
curved edges only; a part placed again the same way takes the arcs
shifted on the view's plane.

compile fix by the personal PC

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

* Drawing views: a view's visible lines as a paper scene group (#5866, W07 V7a)

The paper scene gains a kind for views (ObjectKind::View, layer VIEW).
drawing_view::paper_group turns a view's lines into its group: each
visible piece one stroke, 0.5 mm wide until D2 sets widths by kind of
line, closed when it ends where it starts; a line with no length is left
out and an arc too short to sweep is its chord. W14 calls it for saved
views.

drawing.view.snapshot takes scene: true and replies with the lines as a
paper scene of its sheet, checked by Scene::validate before it is
returned. Hidden lines need dashed strokes (A5, #7430), so scene with
visible_hidden is refused until V7b.

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

* Drawing views: a view draws the occurrences its source names and hide leaves out (#5866, W07 V4)

drawing.view.snapshot takes source: {occurrences: [[ids]]}, the occurrence
subtrees it draws ([] the design's own bodies; every body when not given),
and hide: [[ids]], subtrees left out. A body is drawn when its path is under
a source path and under no hide path, so a hidden path wins over a source
path above it. The rule is one filter on the shown set (ShownSet::keep), so
the extents, the lines and the paper scene follow it; each body costs one
look-up per head of its path, at most 64.

Each path is checked by Session::occurrence_placement as it is (each id in
its parent, no id twice, 1 to 64 ids), with at most 1000 paths in each list
and 4096 ids in all. [] in hide and a choice that leaves nothing are
refused. M2's occurrence tree will make the check one step per id later,
inside occurrence_placement.

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

* Drawing views: a bend seen end on draws its arcs once at any angle (#5866, W07 V4b)

A triangle whose surface is seen edge on at each of its corners (the mesh's own normals, at most
1e-3 toward the view) now faces away, whatever lean its facet has. A thin triangle's facet leans by
the mesh's chord error over its own width, which can be far past the 1e-3 a facet may lean and
still count as edge on. Seen end on, a 90° bend's surface is meshed with its edges' samples at its
ends and its rows at even angles inside, 1.667 mm in; the triangles that join the two lean 1.1e-3
to 5.8e-3, so each one facing the view among its neighbours facing away drew silhouettes along its
sides: chords beside the arcs, 1.14 times the inner arc and 1.69 times the outer. The view DXF
takes the rule too.

The export meshes keep their normals for it: 24 bytes a vertex, a position's size again, counted
in the export's 512 MB mesh budget, so the largest view that budget takes is about 30%
smaller. A mesh without normals keeps its facets' facing.

Tests: the bend test takes a 90° bend beside the 60° one; each bend circle's lines add up to its
arc, less only what sampling takes off.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:25:05 -04:00
CamWight 8679063629 Drawing revisions: rows added, edited and deleted by command, and listed by drawing.inspect (#3874) (#9150)
* cell: rules both kinds of table share (#3874)

Sheet tables (parts lists first) use the title blocks' cell content, so
the rules both read live in cell.rs:

- Source::Row: the row of a sheet table a cell is on. A title block has
  no rows, so its validation refuses a cell that reads one.
- Warning::Width: a word wider than its value box, shown whole, never
  cut: the width warning that measured text brings.
- is_snake_case moves here from title_block.rs: table columns are named
  by the same rule as cells. check_text is shared the same way.

Tests: a title block cell reading a row is refused, beside other parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 640cc3e0f5)

* Sheet tables: the parts-list table model and layout (#3874, P7a)

A Table is a sheet table's definition: its columns, the side its heading
is on (top, or at the bottom with the first row just above it, ISO
7573), its row height (7 mm by default), text height, line widths, a
filter and overrides. A column's cells are the title blocks' cell
content read against each row: row fields and text. Rows are input, in
their listed order, with their values worked out and formatted, so a
filter (parts, assemblies, purchased, text per column ignoring case)
hides rows and never changes a count or a quantity.

Table::layout places it with a text measurer, from the right end of the
heading's outer edge:
- a column without a set width is as wide as its widest line, padding and
  line insets added; a set width is kept;
- every cell in a set-width column breaks between words to fit, and its
  row grows to hold its lines, 10/7 cap heights apart (ISO 3098-0); a
  word wider than its column is shown whole with Warning::Width;
- a row field the row doesn't have shows "-"; an override shows its text
  and keeps the value it replaces, an empty one is a blank cell, and one
  for a row that has gone is kept;
- cells sit inside their lines as a title block's do.

Limits, refused and never truncated: 10,000 rows and overrides, 64
columns, 1 MiB of text in all (each cell's text counted before it is
built), 256-byte keys, a measured width that isn't finite and at least
0, a table off the paper's coordinates.

Nothing is saved or drawn yet: the real measurer, DXF and CSV output and
the command come with P7b.

Tests: hand-worked cell corners and text anchors with a fixed-width
measurer, the heading at the top and at the bottom; wrapping and the
row it grows; a word too wide; overrides, blanks, "-"; each filter
leaving every shown value as given; JSON round trip, defaults and
unknown fields; every limit and one past it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0c5180267d)

* Revision record and table (#3874, W31, P14a)

A drawing's revisions as data (geom::revision): one RevisionRecord for
the whole drawing, or per sheet with each sheet numbered apart.

- A row holds what people type: revision, description, designer,
  checker and date. Adding a row fills in only the next revision;
  nothing fills in a date, a checker or an approval.
- Numbers run from 1, or 0; letters run A to Y without I, O, Q, S, X
  and Z, then AA, AB. The next revision follows the highest in its scope
  among the rows and the issued history, so an issued revision is never
  given out again. A typed revision is kept as typed; in letters one
  with a skipped letter is accepted with a SkippedLetter warning.
- Rows are added, hidden and deleted; issue() appends a copy of a row to
  the issued history, which nothing removes or changes (private, read
  through issued()). Row and tag ids come from the caller (the
  design's random ids), never from a counter, so two people adding rows
  never take the same id; an id in use is refused.
- Triangular tags bind to a row id and show its revision or their own
  text; deleting a row reports its tags, which keep the revision they
  showed, marked lost.
- The revision table is the sheet table (table()): Revision,
  Description, Designer, Checker, Date, heading at the top; hidden rows
  are left out. The table's rows and a title block's revision fields
  (newest(), hidden rows included) read RevisionRow::field, one path.

This is the drawing's revision only: the model's revision and the
export time are kept apart. Release, approval and frozen snapshots
(G16/G22) are not in this slice. Nothing is placed or saved on a sheet
yet (W12, W15).

Tests: numbering both ways with the skips and past Y and YY, typed
revisions and the warning, the next after issued and deleted rows, per
sheet; add fills in nothing else; history kept through hide and
delete; tags reported and lost; the title block's fields and the table
rows from one function, laid out by the sheet table; JSON round trip,
unknown fields and damaged records refused; the caps; an id in use refused.

- An issued copy keeps the model version it was issued against
  (model_version, given to issue(); none until version control gives
  versions, #2869), so the release step has its field here. It is read
  by the label rule, like the revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit fa7849ada7)

* Drawing revisions: rows added, edited, deleted and listed by command (#3874, W31)

A drawing saves one revision record for all its sheets (Drawing::revisions, behind an Arc as the
sheets are, so an undo step shares it; validated with the drawing; its ids among the drawing's ids
in use), in the order its rows were added, the newest last. Pending drawing format: revisions.

- drawing.revision.add {set?}: a row with the next revision, after the highest the drawing has
  given, and any texts given: one undo step.
- drawing.revision.edit {row, set: {revision?, description?, designer?, checker?, date?,
  hidden?}}: the texts as typed (a revision is one line) and what changed; a revision in letters
  with a letter the lettering skips is kept and warned about.
- drawing.revision.delete {row}: the row, and the tags it leaves showing the revision they had.
- drawing.revision.list: every row, the oldest first, and the revision a new row gets.

An id's text form (13 characters of lowercase Crockford base32) moves down to solidcraft_geom::ids,
with an option helper, so the revision record (geom) writes its row and tag ids as every drawing id
is written; solidcraft_doc::ids gives them on, beside IdSource.

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

* Drawing revisions: listed by drawing.inspect, a table row keyed by its id's text, and a record per sheet tested (#3874, W31)

The rows show in drawing.inspect beside the sheets, as `revisions: {rows, next}`, from the same
function the revision commands answer with; drawing.revision.list goes, so a drawing's rows have one
place to be read. A revision table's row is keyed by its id as a file writes it, the one spelling
every drawing id has (a table's cell overrides name a row by its key). A record kept per sheet whose
rows keep its own rules is refused by the drawing, so that rule has a test of its own.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:22:51 -04:00
CamWight f87b66d759 Drawing balloons: the record, its rules and its commands (#3874) (#9126)
* Drawing balloons: the record, its rules and the sheet hooks (#3874)

A balloon points at a part placed in its view's model by an edge of it, saved
by name as a dimension's edge is (ModelEdge), with the occurrence path as the
part it numbers, and sits at a place on paper from its view's centre. The
dimension module's reference checks and strict lookup are shared, not copied.
Balloons go with their sheet, list in on_sheet and take ids from the drawing.
One pending drawing format file; a drawing without balloons saves as before.

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

* Drawing balloons: add, edit and inspect, with their schemas (#3874)

drawing.balloon.add puts a balloon on a saved view, pointing at a placed part
by an edge picked as a dimension's is and saved by name; edit moves it on
paper; inspect says where it stands with the model now, with the arrow's
point on its edge's middle. Add and edit are one undo step each, and none of
them evaluates the model. The status words come from one function shared with
the dimension commands.

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

* Drawing balloons: a copied view takes its balloons, and views and sheets list them (#3874)

A view's balloons are part of what it shows, so copying a view copies them,
with ids of their own, as its dimensions are copied. drawing.view.inspect
lists a view's balloons, and deleting a sheet names the balloons on it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:49:00 -04:00
CamWight a09c24e9bf Drawing dimensions: a circle's or an arc's radius and diameter (#4896, W20a) (#8959)
* Drawings saved inside the design's file, in both forms (#3890)

K3b of the drawing storage plan, after #7098: a drawing lives in the file of the design it draws
(bherbruck's proposal, Cameron's choice), so the separate drawing file goes.

- `Document.drawings`: the design's drawings, each its own record of the drawing family
  (`solidcraft-drawing/N`), each behind its own `Arc`. Every reader and writer of a design now
  carries them: save, Save As, autosave and recovery, crash copies, the CLI.
- A design container keeps each drawing in its own entry, `drawings/<id>.json` (Deflate), beside
  `document.json`, which holds nothing of them: its bytes, and the model cache keyed to them, are
  the same with drawings or without. A plain JSON design keeps them inline under `drawings`.
- Each drawing is read by its own family's rules, taken out of the design's JSON before the design's
  own checks. One a build can't read (a newer number, a change it doesn't know, damage) refuses the
  whole design, never dropped. So does an entry under `drawings/` that isn't a drawing's, one whose
  record has another id, drawings inline in a container's `document.json`, two drawings with one
  id, or more than 1,000 (counted before any is read). A design is never saved with two drawings of
  one id.
- Drawings are listed by name, then id: no stored order, nothing to merge when two people add
  drawings.
- A drawing has an `id`, unique among its design's drawings (`Drawing::new` takes the ids already
  taken). Its source is the design it is in: at most one, an id and no path.
- One pending design change, `drawings`: the last release refuses a design saved with it, so it
  never opens one and saves it without its drawings. One pending drawing change, `in-design`, for
  the record's new shape.
- Gone: `read_drawing`, `read_drawing_file`, `write_drawing_container`, `file_kind`, `FileKind`,
  `DRAWING_EXT` and `is_drawing_path`. `read_document`, `read_design` and the container writer are
  as they were before #7098, with the drawings added.

Tests: a design's drawings round-trip through both forms byte for byte, with `document.json` and
`container.json` the same with drawings or without; their order; every refusal above; a drawing's
properties kept as other tools wrote them while a sheet's number out of range is refused; the
drawing's id among its objects', a new drawing's id none of the design's; a design with two
drawings of one id not saved; mutation fuzz of a design with drawings in both forms.

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

* Drawing commands on the design's own drawings, each drawing its own undo (#3890)

K4' of the drawing storage plan, on K3b (a drawing lives in its design's file): make, list, open,
close and delete a design's drawings, and read and change the open one's settings and properties.
It replaces #3891's drawing sessions, which held a drawing as a file of its own.

- `drawing.create {name?, language?, units?}` adds a drawing of the design (one ISO A3 landscape
  sheet with the ISO border, the design its one source) and opens it; refused on an empty design.
  `drawing.list`, `drawing.open {drawing}`, `drawing.close`, `drawing.delete {drawing}`.
  `drawing.inspect`, `drawing.set`, `drawing.properties`, `.properties.set`, `.properties.remove`
  work on the open drawing. Labels in en, ja and zh.
- The open drawing is the session's (`Session::open_drawing`, kept in checkpoints), and the part
  gate keeps the parts apart: a command says the part it works on, the model unless it says
  otherwise (`.in_drawing()`, `.any_part()`). While a drawing is open the model's commands wait
  ("a drawing is open: close it to change the model"); the drawing's run only while one is.
  Files, recovery, undo and redo, checkpoints, selection, diagnostics, the command list and
  choosing a drawing work either way. `CommandSpec::allowed` is the one check running a command,
  running it inside another and listing it make.
- `Session::drawing` and `Session::edit_drawing` are the open drawing's one way in: an edit is kept
  when it changed something and still keeps the record's rules, in a new design whose other
  drawings are shared.
- A change that only touches drawings evaluates nothing: `Document::differs_only_in_drawings`
  joins the render-only path, as render settings and part properties do. A model edit shares
  every drawing, so it pays nothing for the check.
- Undo per part: a step records the part it changed (`Snapshot::part`: a drawing, or the model
  and its list of drawings). Undo and redo go through the open part's steps only. A drawing's
  step puts back only that drawing; the model's step puts back the design as it was, keeping each
  drawing it had as it is now. Making and deleting a drawing are the model's steps. A new step
  ends what could be redone in its own part only. `Snapshot::of` is the one place a step is
  taken.
- An agent's message that changed several parts keeps their steps apart, rather than merging them
  into one step that would undo only one part.
- `cmd::metadata`'s rules for reading, setting and removing properties are written once
  (`reading`, `setting`, `removing`) and used by the design's and the drawing's commands.
- `drawing::sort` is the one place the drawings' order (by name, then id) is kept;
  `Document::put_drawing` adds or replaces one in that order.

Tests: making, listing, opening, closing and deleting; what making one refuses, at the cap too; the
gate both ways, inside another command and in the command list; drawing edits as undo steps that
evaluate nothing over 100 rounds; undo and redo per part with model and drawing steps interleaved;
redo ended per part; the order by name, then id, after renames; delete and its undo; refused
changes; checkpoints; save and open in both forms; properties; hostile values for every command
that runs with a drawing open, from the registry; a fuzz of the steps in any order.

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

* Unknown-command tests: the closest id first (#3890)

drawing.open now joins doc.open in the suggestion for `file.open`, as the rule says: both end in
the same word. The engine's and the CLI's tests check that doc.open comes first and let other
close ids follow, as the `solid.extrud` line beside them already does.

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

* Model bodies: their extents seen on a plane, for a drawing view's size (#5866)

ModelBody::mesh_bounds_in(plane) is the box of a body's mesh in a plane's own coordinates
(Plane::to_local), beside mesh_bounds and in the same way: a body meshed already is measured from
its own mesh, and a placement of a part from its definition's shared mesh, with the placement
folded into the plane's two axes once (two dot products a point), so no moved copy is made. W07's drawing.view measures a view's extents with it.

Tests: a 100 x 50 x 20 block seen on the three principal pairs of axes and from an isometric
corner (150/sqrt(2) by 190/sqrt(6), worked by hand), a plane away from the origin, and every
placement in the shared-mesh test against its moved mesh, still without a moved copy, and the
same again from its own mesh once it is meshed.

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

* Drawing views: projected views in first or third angle, lined up with their parent (#4816, W17a)

W17a's second slice: the principal views derived from a base view.

- `ProjectedSide` (left, right, above, below): the side of its parent a
  projected view sits on.
- `projected_basis(parent, side, projection)`: in third angle a view shows
  the side of the model it sits on (one to the right looks from the right), in
  first angle the side away from it (ISO 5456-2). It's the parent's basis
  turned a quarter turn about one of its paper axes, through its origin, with
  no -0. From the front view it gives exactly the named views: right, left, top
  and bottom in third angle; left, right, bottom and top in first.
- `ViewFrame::aligned(side, projection, centre, position_mm)`: a projected
  view at its parent's scale, lined up with it, so it moves only along the free
  axis.
- The projection is S2's `cell::Projection`, the value the title block's
  symbol reads, so a drawing has one first-or-third setting.

Tests: the front view's projections are the named views in both angles, with
no -0; from every named view and a leaning one, a projected view looks from
its side and keeps the parent's other axis and origin, four turns or a turn
and its opposite bring the parent back; a box's corners line up across
aligned views in both angles; hostile parents, centres and positions refused.

Corner (isometric) projected views wait for one convention check on first
angle. The derivation graph (projected from, aligned to, cycles refused) moves
to the view record in the drawing file, where the view ids live.

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

* Drawing dimensions: measurement on view frames (#4896, W19a)

`geom::drawing_measure` is what a dimension on a view reads, in model mm or radians, from model
points and edges and the view's basis: one rule for every dimension. The view's scale and place
on the sheet never enter.

- `linear`: horizontal and vertical along the view's paper x and y, aligned as the view shows
  the segment, or along a direction picked on the view; a true reading is the segment's own
  length (aligned only).
- `angle`: from the view's horizontal or vertical, a model axis as the view shows it, or
  another edge, from 0 to π as each line is oriented; a true reading is between model lines.
- `foreshortened` says a measured line isn't parallel to the view's plane.
- Refused with a reason: points that aren't finite or are past 1e12 mm, an invalid basis, a
  zero-length segment, a line seen end-on, a zero direction, a zero reading, parallel lines,
  and a true reading where there is none. `drawing_view`'s `PARALLEL` and `require` are shared.

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

* drawing.view.snapshot: a view's scale, place and boundary on its sheet (#5866, W07 V2)

The first slice of W07: one orthographic view of the design, worked out and returned as data,
before any line is drawn, so scripts can lay views out by their boundaries.

- `drawing.view.snapshot` takes the view's settings, the sheet it goes on (A3 landscape at
  300 dpi when not given) and where its centre goes (the sheet's centre when not given). With no
  scale it takes the largest ISO 5455 scale at which the view, centred there, lies inside the
  sheet's drawing border. It returns the resolved view, the scale (and whether it was fitted),
  the extents on the view's plane, the boundary on the sheet and what it was drawn from.
  Read-only and not undoable; it opens nothing and saves nothing.
- Every body the design places is in the view, wherever its occurrences place it; the browser's
  eye counts for nothing (`engine::drawing_view::extents`, from P2's `mesh_bounds_in`). A design
  whose assembly walk stops at its limits (deeper than 64 levels, or past 100,000 placements) is
  refused rather than drawn with parts left out: `Document::placements_complete`, beside the walk,
  says whether every placement it holds had its occurrences walked.
- `doc::drawing_view::ViewSettings` is the view's one parameter set: the view (named, or a
  direction and an up), the scale, the two visual styles, the reference settings and tangent
  edges. A saved view (W12) keeps the same fields; no scale is written as none, not null, so
  what it writes reads back as the snapshot's parameters.
- `render::VisualStyle` names the five styles: visible, visible_hidden, shaded, shaded_visible
  and shaded_visible_hidden. The shaded ones are refused as not supported yet until the drawing
  renderer (#5624).
- The command has a typed contract (input and output schemas). Its handler reads the contract's
  own type through `schema::read`, in the contract check's own words, so the check's levels give
  one message for one mistake and an unknown field is refused by name. The reply includes the
  sheet, its defaults filled in.

Tests: the 100 x 50 x 20 block's boundary in the front, top, right and isometric views at 1:2,
worked by hand; the fitted scale on A3 and A4 and near the border; scale, position and margin;
the browser's eye ignored; the design untouched; each hostile setting refused; the walk's
depth limit one level either side; the contract's keys the same as the settings' own.

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

* Drawing views in the drawing record: base and projected views, and the projection (#4816)

E4 of the view-boundary plan, with #4816's derivation graph (plan on #4816).
A drawing now saves its views, as one pending drawing-format change
(`drawing/format/pending/sheet-views.rs`):

- `Drawing.views`: each view an object of the drawing, on one of its sheets.
  A base view draws a source from a direction (`ViewDirection`, as
  `drawing.view.snapshot` takes it); a projected view sits on one side of its
  parent. Its place is a point of the sheet, or, lined up with its parent, only
  how far along the free axis, as the other comes from the parent. A projected
  view's source and basis come from its chain, so neither is saved.
- `ViewOverrides` beside V2's settings: what a view sets itself, each setting
  on its own (scale, display style, reference style, reference parts hide the
  model, tangent edges); none is written when left out. Each view has its own
  reference margin.
- `Drawing.projection`: first or third angle (S2's `cell::Projection`). Third
  angle is the type's own default (Cameron, 2026-10-26), for a drawing that
  doesn't say and for a new one until a template or a preference chooses.
- The rules, checked on reading and after every edit: a view is on one of the
  drawing's sheets; a base view draws one of its sources, has a scale and a
  direction that makes a view; every parent is a view, no chain of parents
  loops, and a chain is at most 32 views; a lined-up view is on its parent's
  sheet; a place is on paper; at most 4,096 views. A view's id is the
  drawing's, never another object's.
- `Drawing::resolve_view`: one rule for every reader. It turns the base view's
  basis down the chain (`projected_basis`, in the drawing's projection) and
  takes each setting from the nearest view that sets it, then the default,
  saying who gave it.
- geom's `is_paper_coordinate`: the one rule for a view's place on paper, now
  used by `ViewFrame::new` and the record.

The record tests' seed drawing has views, so its round trip and mutation tests
cover them; their unknown-field examples no longer use `views`.

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

* Engine tests: one temporary folder helper and the schema check for every test module (#4816)

The view tests use the drawing tests' temporary folder and the schema tests' `conforms`, which were
private to their modules. `Folder` moves to the engine's test module and `conforms` is open to the
crate.

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

* Drawing views: inspect and edit a saved view (#4816, W17c)

E5 of the view-boundary plan (plan on #4816): what the view's window reads and
writes, for the views of a drawing (E4). Both are drawing commands with
input and output schemas, and neither evaluates a model.

- `drawing.view.inspect {view}`: its kind (base or projected), sheet, source,
  parent and side, its direction as resolved (named only when it is exactly a
  named view), its place, and each setting as {value, from, parent?, set}:
  where the value comes from (the view, a parent by id, the default) and what
  the view saves itself. A projected view says whether its display settings
  follow its parent (`linked`: true, false or "partly"). `choices` gives the
  display styles and the preset scales, so a window keeps no list of its own.
- `drawing.view.edit {view, set}`: one undo step. Each setting takes a value,
  or null to follow the parent (a base view keeps a scale); the margin, a base
  view's direction and the place change too. `linked: false` saves the display
  settings at the values they have, `linked: true` clears them so they follow.
  The reply names the fields changed and the views projected from it whose
  settings changed with it. The record's rules hold after every edit; an edit
  that changes nothing is no step.

The contract test keeps the edit's fields and the record's (ViewOverrides) one
list. The view's boundary comes with the next slice, which works out where each
saved view sits on its sheet from its design's extents.

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

* Drawing views: where a saved view sits on its sheet (#4816)

`Drawing::view_centre` gives a view's centre on its sheet: its point, or, lined up with its
parent, its own place along the free axis and the parent's centre across it. The free axis is
one rule on `ProjectedSide` (`along`, `on_free_axis`), which `ViewFrame::aligned` uses too.

`engine::drawing_view::on_sheet` puts a saved view on its sheet: its design's extents on its
plane, grown by its reference margin, at its scale, round its centre. The snapshot and saved
views share `measure` (refusing an assembly past the walk's limit or one with no bodies) and
`boundary`. With nothing to measure (the design has no bodies or places more than a view walks) a
view gets a 20 x 20 mm placeholder round its centre. A view's source is the design the drawing
is in, so the session's own model is what it measures.

`drawing.view.inspect` adds `boundary_mm` and `extents` (status `placeholder` and why, when there
is nothing to measure). It still evaluates no model.

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

* Projection of new drawings: a preference, and drawing.set takes the projection (#4816)

A drawing's views follow its projection, first or third angle (third when nothing chooses).
`drawing.inspect` now shows it and `drawing.set {projection}` changes it as one undo step.
Preferences > Design gets a Drawings section with "Projection of new drawings"
(First angle / Third angle). A new drawing takes it as a new design takes "Units of new
designs": the app sets it after `drawing.create`, with no undo step of its own. A saved
value this version doesn't know reads as third angle; asked for over the control channel,
it is refused.

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

* Drawing sheets: one edit, one lookup, a border that lays out, and schemas (#3875)

The sheet commands change the drawing through `Session::edit_drawing`, so the same settings or
order again leave no undo step, and answer from one reply type. A sheet is looked up by id or
name on `Drawing` (`sheet_index`), where later commands that name sheets find it too.

A sheet's border must lay out on it: the record's check runs the border's own layout, the one
rule drawing uses, so a 15 x 15 mm sheet with the ISO border is refused when it is made, not
when it is drawn. `excluded` must be an object of `count` and `print`: a mistyped key changed
nothing unseen. A new sheet documents nothing and leaves nothing out unless told, and the add's
parameter documentation says it takes `documents` and `excluded` too.

The drawing's properties sit behind an `Arc`, as its sheets do, so an undo step shares them.

Each sheet command has an input and an output schema (#4035). A flattened part of a contract
reports its keys as the parent's own (`schema::flattened`, which `FeatureCommon` now uses).
The tests add the missing cases: 1000 sheets and the 1001st, `after` by id and unknown, the
resolution's bounds, a name two sheets share, the same order and settings again, an undone
delete in place, and the add's reset of what it documents and leaves out.

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

* Drawing ids: one id source per session, its seed kept for replays (#3890)

The drawing commands drew ids from a fresh random source each time, so a crash replay of a drawing
could never find what an earlier command made by its id. A session now has one id source
(`Session::ids`), kept across a file change, from which `drawing.create` draws its drawing's,
sheet's and source's ids: seeded alike and run alike, a session gives the same ids, and a failed
command draws none, so a replay of what succeeded draws the same. The crash journal keeps the seed
at each base and `replay.json` carries it, which `solidcraft-cli crashes replay` seeds the replay
with (`Session::seed_ids`).

Tests: two sessions seeded alike make a drawing with the same ids, another seed other ids.

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

* Drawing sheets: add, change, reorder and delete a drawing's sheets (#3875)

W18 S4: the sheets of a drawing, on the drawing session K4 made (#3890). Each command is one undo
step of the drawing and names a sheet by its id or, when only one has it, its name.

- `drawing.sheet.add {after?, name?, size?, orientation?, dpi?}`: a new sheet after the one named
  (the last when not), taking that sheet's paper, border and resolution, with a name of its own
  ("Sheet 2") and nothing documented yet.
- `drawing.sheet.set {sheet, name?, size?, orientation?, dpi?, documents?, excluded?}`: one
  setting at a time, the rest kept; `documents: null` clears what it documents, and `excluded`
  changes only the flags it gives.
- `drawing.sheet.reorder {sheets}`: every sheet's id once, in the new order.
- `drawing.sheet.delete {sheet}`: a drawing keeps at least one sheet.
- Every change goes through the drawing's own rules (`Drawing::validate`), so what a saved file
  would refuse is refused here. Changing a sheet's paper changes only the paper: regrowing its
  border and moving what is on it is S5's. Labels in en, ja and zh.

Tests: a new sheet copying the paper it follows, after a named sheet too, and undone; each setting
changed with the others kept; nine refusals (resolution, sizes, orientation, name, an unknown
source, a flag, an unknown sheet, no sheet named) with the drawing unchanged; reordering, and four
orders that aren't every id once; a name two sheets share refused in favour of the id; the last
sheet kept.

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

* Drawing sheets: a new sheet's id from the session's id source (#3875)

`drawing.sheet.add` draws its id from `Session::ids` (#3890's one id source per session), so a
crash replay of "add a sheet, then change it by id" finds the sheet: seeded alike and run alike, a
session adds sheets with the same ids, and a failed add draws none.

Tests: two sessions seeded alike add the same sheet ids, another seed others, a failed add none.

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

* Drawing views: move or copy a view to a sheet (#4816)

`drawing.view.move_to_sheet {view, sheet, position_mm?, break_alignment?}` puts a saved view on
another sheet as one undo step, keeping its id, direction, source and settings. The views lined
up with it go with it and keep their places beside it; a view placed apart stays. Without a
position it keeps its place. A view lined up with its parent moves only with `break_alignment`,
and is then placed apart: at the position, else the sheet's centre. Its own sheet changes
nothing.

`drawing.view.copy_to_sheet {view, sheet, position_mm?, with_linked?}` makes new views, each
following none of the views it was copied from (Cameron's rule). A view with views lined up
with it needs `with_linked`: true copies them too, lined up with the copy; false copies it
alone. A projected view copied alone becomes a base view, looking the way it did (by name when
it is exactly a named view) with the settings it had as its own. The copy goes to the position,
else the sheet's centre.

A lined-up view's centre comes from the places alone: its place along the free axis, and its
parent's centre across it. So the views that travel with a view are shifted along their free
axis, and nothing needs the design's extents. `descendants` takes which views it follows, so the
edit's followers and the views lined up with a view are one walk.

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

* Drawing views: move and copy take a view's centre from the record (#4816)

Moving or copying a view to a sheet worked out a view's centre with its own copy of the
lined-up rule. It now uses `Drawing::view_centre`, and a lined-up view's place moves along
its free axis by `ProjectedSide::along`, so the rule is written once.

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

* Drawing sheets: a change of paper lists the views it leaves outside, and moves none (#3875)

W18 S5, its first part. `Sheet::drawing_space` is where a sheet's drawing goes: inside its
drawing border, or the whole paper with none. Nothing on a sheet is saved against the border,
so a change of paper regrows the border and its zones by itself.

`engine::drawing_view::outside` lists the views whose boundary (`on_sheet`) leaves their sheet's
drawing space, sides included. `drawing.sheet.set` lists them after a change of paper (size or
orientation; nothing else moves the space, so nothing else is measured), and moving or copying a
view to a sheet lists those it puts outside there. None is moved: where a view goes stays the
author's choice. A view with nothing to measure is listed by its placeholder, with why.

Tests: the drawing space with the ISO border, a filing margin and no border, and margins that
leave none; A3 to A4 either way up listing the right and top views with their boundaries and
moving nothing; a border through a view's edge leaving it inside, 0.1 mm less paper putting it
outside; a change of name checking nothing; undo; a move to a smaller sheet listed and a copy
that fits not; a placeholder listed as `past_walk_limit`.

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

* Drawing ids and the sheet border: the tests the slices left out (#3890, #4816, #3875)

What the slices' planted faults found untested, now each caught by a test:
- a file change keeps the session's id source: a drawing made after `file.new` draws the same ids
  in two sessions seeded alike;
- a copied view's ids come from the session's id source, seeded alike giving the same copies;
- a crash report's replay carries the session's id seed, from a caught panic (the seed when the
  command started) and from the journal of an unclean exit (the seed at its base);
- a view short of the sheet's border by less than a rounding is still inside it.

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

* Drawing views: listed by id, whatever order they were added in (#4816)

The record's rule says a drawing saves its later records by id, never in
the order they were added in. Views were kept, and saved, in the order they
were added, so the same views added in another order saved as other bytes.

- `Drawing::add_views` adds views where their ids put them. It is the one way
  views are added: `drawing.view.copy` uses it, and so do the test fixtures.
- `Drawing::validate` checks the order, so every edit keeps it.
- Reading puts a file's views in id order before it checks the record. The
  order means nothing, so a file in another order (saved before views went by
  id, or edited by hand) isn't damaged: it opens, and saves by id.
- What a command lists in the drawing's order (the views a change of paper
  leaves outside, an edit's followers) now comes by id.

No format change: no release has saved a view (the views change is still
pending).

Tests:
- views added out of id order are listed and saved by id;
- the same views added the other way round make the same drawing and the same
  bytes, which read back as saved;
- a file that lists them the other way round reads by id, as the same
  drawing, and saves as those bytes;
- an edit that leaves them in another order breaks the record's rule;
- the existing tests now index the file by id order, and the engine's lists
  are by id.

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

* Drawing dimensions: the dimension record, saved by name on a view (#4896, W19b)

A drawing saves its dimensions: each on one of its views, measuring named
corners or edges of the view's model (a linear distance between two corners,
a straight edge's length, or the angle between two edges), with the direction
and reading W19a measures with, how far its line sits from what it measures,
where its text sits on paper, its decimals and any text shown instead of the
number. A reference names its body, the feature that made the body and its
occurrence path, never a picked point. Nothing worked out is saved.

- Dimensions are listed by id, read in that order whatever order a file has,
  as views are: one helper pair (add_by_id, in_id_order) now keeps both lists.
- One cap for the names a saved model reference keeps
  (MAX_REFERENCE_NAME_BYTES, used where 512 was written three times), and the
  corner's edge cap shared with construct's vertex references.
- LinearDirection and Reading save as snake_case words; a direction along a
  line saves as its two coordinates.
- A direction along a line is at most 1e9 each way (format::MAX_REAL, the
  limit the file's number check holds every real number to): a whole number
  past it passes that check, reads as a real, and would save as one the next
  read refuses (found by the mutation test).
- One pending drawing-format file (dimensions.rs).

Tests: what each dimension saves and reads back as the same bytes, a file in
another order reads by id, an older drawing has none; every rule on both sides
of its cap, unknown fields at every level, the id rule, and the count cap; the
plate fixture carries one dimension of each kind, so the existing round-trip
and mutation tests cover them.

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

* Edges and corners by name: the matching step and the shared ends, each in one place (#4896, W19b)

A dimension finds an edge by name the way a fillet does, but never takes
the piece nearest an old pick, and finds a corner where its named edges
meet, refusing to choose between two. Both steps are split out of the code
that already does them, so there is one rule each:

- BodyEdges: a body's edge and face names, worked out once for every name
  looked up on it, and `matching(name)`: the edge with that name, else the
  one edge between the faces it names, else the pieces of it. locate_edge
  now calls it and then takes the piece nearest the pick, as before.
- construct::shared_ends: the ends every named edge has, each once.
  named_vertex keeps the one nearest its point, as before.

No behaviour change: every caller of locate_edge, find_edge and the vertex
references gets the same results. The existing naming, joint and
construction tests cover both paths; three new ones pin what they left
open: a vertex only where every named edge ends, the piece of a split
edge nearest the pick, and a lone match through the stem of a face taken
as a piece, with the warning.

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

* A body's frame through an occurrence path, in one place (#4896, W19b)

Document::body_frame_at(body, path): the placement the path leads to,
then the body's offset, without expanding every assembly instance. It is
what EmbossFace::body_frame composed for its saved path; that now calls
it, and drawing dimensions use it for the occurrence path they save. No
behaviour change.

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

* Drawing dimensions found again in the model, strictly (#4896, W19b)

Document::resolve_dimensions(model, drawing, ids) works out each dimension
as the model has it now:

- An edge by its name, or the one edge between the faces it names
  (BodyEdges::matching), never the piece nearest an old pick. A corner is
  the one end all its named edges, or their pieces, share; two shared ends
  are ambiguous, never "the nearer one".
- The body by its name and the feature that made it, placed through the
  saved occurrence path (Document::body_frame_at). A same-named body made
  by another feature isn't it.
- Status: ok; overridden (the text given is shown, the value still worked
  out); ambiguous (the candidates, at most 16, none chosen); suppressed (its
  feature is suppressed or rolled back, read from the evaluation's own
  skipped mark); lost (why); unmeasurable (found, but the view can't
  measure it so, in W19a's words, such as a line seen end-on).
- The value comes from W19a on the view's basis. An angle is the smaller one
  between the two lines, or pi minus it on its other side, whichever way the
  kernel runs the edges; a split edge's pieces on one line are still that
  line. A split edge's length is ambiguous.
- The text: the override, else length_number in the drawing's unit (or the
  design's) with the dimension's decimals, or degrees for an angle.
- The cost: each body named is looked up once per call, and its edge names
  worked out once, however many dimensions name it. A body none names isn't
  touched.

Not saved: nothing here changes the drawing. The last good value of a lost
dimension needs the session, so it comes with the commands (W19d).

Tests (engine, an 80 x 40 x 30 box, values by hand): front and right view
values, foreshortening and text, the drawing's own unit, several at once
and an unknown id; a change and its undo; a chamfer's 45 and 135 degrees,
and end-on in the front view; lost after a fillet and back on undo,
suppressed and back, a path to no occurrence, a new box with the same body
name; a notched edge's length ambiguous with both pieces while its end
corners still read 80 and its angle to the left edge 90 degrees, neither
exact; an angle on a piece of an edge whose slot is then suppressed still
reads 90 degrees, found through the stem of the face it was cut from, so
not exact; one edge named twice gives two corner candidates;
an override shown while the value follows the model.

Not covered by a test here: a part placed twice through real occurrences
(only a path to no occurrence is); the once-per-body cost (not measured).
A drawing unit outside display_units' five (um, yd, km, mil, nm, inch, µm)
shows millimetre numbers, the formatter's existing fallback; named as its
own follow-up.

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

* Drawing sheets: a sheet with anything on it goes only with it, when asked (#3875)

drawing.sheet.delete refuses a sheet with anything on it, and its
error names what is there: its views and the dimensions in them, the
first few ids of each. With `contents: "delete"` the sheet goes with
everything on it as one undo step, which an undo brings back whole.

What is on a sheet is said once, in the record (Drawing::on_sheet),
and Drawing::remove_sheet takes the sheet out with exactly that; each
later kind of record on a sheet (a sheet's own sketches) adds itself
there, so delete covers it with no change here. drawing.sheet.set's
list of a sheet's views uses the same function.

Nothing on another sheet goes with it: a view there projected from one
of the sheet's views would lose its parent, so the delete is refused
naming those views, to be moved or deleted first.

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

* Drawing views: a view found by id by halves, in one place (#4816)

The views are listed by id, so Drawing::view and Drawing::view_index
find one by halves instead of walking the list. Every lookup by id
uses them: a view's chain of parents (so resolving a view no longer
walks the list once for each parent), the views' rules (which no
longer build a map of every view to check each chain), a dimension's
view, and the engine's view commands and outside check.

The rules check the id order before anything looks a view up, so a
file with its views out of order is still refused as before.

Measured on a drawing of 4096 views (1024 chains of a base view and
three views projected one from another), median of 30 runs: 4096
lookups by id took 6.85 ms by a linear find and take 105 us by
halves; the rules' check took 1.77 ms and takes 1.45 ms.

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

* Drawing views: a damaged chain names the view whose parent is missing (#4816)

A view whose chain of parents breaks further up was refused with "its
parent X isn't one of the drawing's views", naming a view that isn't
its parent. Further up the chain, the refusal now names the view whose
parent is missing: "its chain of parents reaches view Y, whose parent
X isn't one of the drawing's views". A missing parent of the view
itself reads as before.

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

* Drawing dimensions: add one to a view, and read it back (#4896, W19d)

drawing.dimension.add puts a dimension on a saved view of the open
drawing; drawing.dimension.inspect reads one back with what it
measures now. Adding is one undo step of the drawing; neither
evaluates the model.

What a dimension measures is picked and saved by name, never by the
point picked. Document::edge_reference and corner_reference turn a
pick into the record's names: an edge is a point on it in the design's
frame or {body, index} (its index among the body's edges as the model
lists them, as an ambiguous dimension's candidates are given), and a
corner is a point or the edges that meet there, each with the
occurrence path its body is placed through. A pick that doesn't name
one thing is refused, saying what it found: no edge, a point on
several edges, a corner of more than one body, edges that don't meet.
Each body's edges are placed in the design's frame once per pick, and
only the body picked has its edge names worked out.

The kind is given as the record saves it ({linear: {a, b, direction?,
reading?}}, {length: …}, {angle: …}), read by the record's own types,
so a command and a file read the same words. A new dimension's line is
10 mm from what it measures (until a dimension style gives it), shows
2 decimals, and its text is centred on its line; the text's default
place at its centred point (Q58) comes with the layout on paper.

The inspect reply has the record, the status (ok, overridden,
ambiguous, suppressed, lost or unmeasurable, with why), the value
(model mm, or degrees for an angle), whether it is foreshortened, the
text, whether every name was found by exact history, and an ambiguous
dimension's candidates.

One rule, one place: the corner rule's point tolerance is
construct::point_tolerance, shared by the picks; a dimension by id is
Drawing::dimension, which the resolver uses too.

Not here yet: edit, the view's list of its dimensions, the layout on
paper and the last good value (the next slices); delete waits with
view delete for the drawing UI's choice of one delete command or one
for each kind.

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

* Drawing dimensions: change a dimension's settings (#4896, W19d)

drawing.dimension.edit {dimension, set} changes what a dimension
shows and where, as drawing.view.edit changes a view: the offset of
its line, its text's place, its decimals, the text shown instead of
its number (null shows the number again), and its kind's direction,
reading or other side. The reply names the fields that changed. The
same values again change nothing and add no undo step (the drawing is
put back only when it differs). What a dimension measures isn't a
setting: to measure something else, add a new one.

A setting its kind hasn't got is refused (an angle has no direction,
only an angle has an other side); every changed record passes the
check a file read runs, so an edit can't save what opening the file
would refuse. A dimension's place in the list is
Drawing::dimension_index, which Drawing::dimension now uses too, as a
view's is.

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

* Drawing views: inspect lists a view's dimensions (#4896, W19d)

drawing.view.inspect answers `dimensions`: the ids of the view's
dimensions, in id order, so a caller can find them and read each with
drawing.dimension.inspect.

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

* Drawing views: a copied view takes copies of its dimensions (#4896, W19d)

drawing.view.copy_to_sheet copies each copied view's dimensions with
it, each with an id of its own on the copy, in the same undo step:
they are part of what the view shows (the W19d plan's question 1, its
recommended answer). A dimension's text placed `at` a point is from its
view's centre, so it keeps its place on the copy. A moved view keeps
its own dimensions as they are: they name the view by id.

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

* Drawing dimensions: the last good value, in memory (#4896, W19d)

A dimension that can't be measured now (its edge filleted away, its
corner gone) gives `last_good`: the value it last read this session,
as `value` gives it (model mm, or degrees for an angle). Each value a
dimension reads is kept by drawing and dimension in the session, and a
scratch copy keeps the session's. It is never saved (the W19b plan's
question 3), so a design's drawings don't change with every model
edit, and after reopening a lost dimension shows no number.

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

* Drawing dimensions: hostile values anywhere in the commands' params (#4896, W19d)

A hostile value in each place of drawing.dimension.add, .edit and
.inspect's parameters, at every depth (the kind, its picks and paths, a
corner's edges, the edit's settings), with 1 000-item lists, the
largest index and 600-byte text besides the engine's shared hostile
values: no panic, and the drawing keeps its rules. The engine's own
hostile test sends only its list of key names, none of which these
commands read.

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

* Drawing dimensions: a new dimension's defaults, tested (#4896, W19d)

The offset (10 mm), the text's place (centred) and the decimals (2) a
dimension is added with when not told.

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

* A plane carried by a transform, in one place

`assembly::map_plane` is public and gives `None` for a transform that flattens the plane; its two
copies (the occurrence mapping, which keeps the plane as it was, and the face anchor's, which
refuses) now call it and keep their own fallback. W20a's radial dimensions use it to carry a
view's plane into a body's frame.

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

* Drawing dimensions: a circle's or an arc's radius and diameter (#4896, W20a)

A radial dimension saves its edge by name and whether it shows the diameter. It reads the edge's
exact circle in the view's plane through the edge (`Body::planar_edges`, as the view draws it), so
a view that doesn't see the circle face on can't measure it; its text is R or Ø before the number.
`drawing.dimension.add` takes `{radial: {edge, diameter?}}`, a diameter by default for a whole
circle or half a turn or more (a hole's rim is two halves), and refuses a dimension of any kind its
view can't measure; `drawing.dimension.edit` switches `diameter`, keeping the id.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:56:06 -04:00
Mateusz Sych ebafe07487 Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455) (#2457)
* Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455)

crates/geom/src/hlr (L0): prepare once per body, then hidden_lines for a
scene of placed bodies. Edges and face silhouettes are projected from their
exact curves, split where projections cross, and each piece is classified by
rays toward the viewer against the trimmed faces: visible, hidden or
explicitly uncertain, never guessed. Planes, cylinders, cones, spheres and
tori are supported whether stored as analytic records, revolutions or spline
patches; freeform faces are reported and make what they could hide
uncertain. Screen geometry is Seg2 with the W01a accuracy vocabulary: exact
lines and conic arcs where the projection is exact, bounded chords
otherwise. docs/drawing-hlr.md holds the contract and its limits.

* geom/hlr: handle the brep records main gained in #3093

Main's exact model storage (#3093) added Curve::SurfaceIntersection,
Surface::PlaneThrough and Surface::PlacedRevolution, and the HLR matches on
the records no longer compiled. A plane through three points is a plane: it
is trimmed and intersected as one, and a box stored that way draws the same
bits as one with axis-stored planes. The procedural intersection curve and
the placed revolution (whose affine placement may scale it unevenly) are
refused by prepare as not supported yet, until the kernel adapter handles
them. The records' positions move with the body's centre like the others.

* geom/hlr: pad plane faces' boxes, so a plane record off by rounding still hides

Plane faces took the box of their boundary chords unpadded, flat along the
normal. When the plane record and its edges disagree by rounding (a unit cube's
top plane one ulp above its edges, a 100 mm cube's 1e-11), the ray toward the
viewer reached the plane just outside that box, the slab test culled the hit,
and the back edges came out Visible with no doubt. Every face's box is now
padded by 1e-6 of its size, as curved faces' boxes already were. Reported by
DevonHope on #2457.

* geom/hlr: limits from review: trim samples, scene extent, crossing pairs, surface groups

From DevonHope's review of #2457:
- Options::trim_samples must be 1 to MAX_TRIM_SAMPLES (4096).
- Placed bodies must stay within MAX_COORDINATE (1e12 mm, as section and brep
  bound stored numbers). Placements 1e200 apart made the scene's size infinite
  and every tolerance taken from it with it, so everything came out Visible.
- The crossing search counts its candidate pairs before it starts and meets
  each pair only in the first grid cell the two segments share, instead of
  keeping a set of the pairs met, which could outgrow wasm's 4 GB before the
  500 million pair budget tripped. The same pairs in the same order.
- prepare groups faces on one quadric or torus by comparing each face with
  the faces of its kind whose size agrees to the tolerance (sorted), not with
  every face; at most 50 million comparisons. prepare_cancellable polls a
  cancel callback between edges, faces and comparisons.

* geom/hlr: boundary chords on a grid, and no fine pass over coincident curved chords

The cost items of DevonHope's review of #2457:
- Every ray hit asked how far it lies from its face's boundary by scanning all
  of the face's chords (4096 per circle; a plate's face with 256 holes has
  about a million). The chords are now bucketed on a uniform grid over the
  boundary's box, cells at least as long as the longest chord, and the test
  looks only at the cells around the point. It finds exactly the chords the
  scan finds.
- Chords of two curved curves that lie on each other end to end (aligned holes
  seen along their axes) were cut into 16 by 16 pieces and intersected, to
  find crossings that are only noise within the curves' tolerance. They are
  now skipped; visibility changes where a coincident run starts or ends, and
  chords that only partly overlap still find those.
- Fixtures: a plate with holes, coaxial cylinders seen along their axis, and
  300 placements.

* geom/hlr: pad boxes by the body's coordinates too; coincident chords only to rounding

Review of the previous commits found two holes:
- The padding grew with the face's size, but rounding grows with where the body
  was modelled: a 0.001 mm cube 1e7 mm out, its top plane one step (1.9e-9 mm)
  above its edges, still had its hit culled. The pad now adds 1e-12 of the
  body's offset.
- Two chords were taken for coincident when their ends lay within each other's
  sag, which in a coarse scene can hide real crossings: an arc can bulge across
  a line 1 mm off its chord twice. Only chords that coincide to rounding are
  skipped now; identical projections, the costly case, still are.

* geom/hlr: coincident curves meeting at shared sample points do not cross

Timing the review's scenes showed that skipping coincident chords was not
enough: chords of two coincident curves also touch end to end at every sample
point they share, and each touch was a crossing to refine and split at. 64
coaxial cylinders seen along their axis gave 8.3 million crossings (19 s), 64
cylinders one behind another along the line of sight 4.2 million (214 s;
before these commits the crossing budget refused it). A touch at a shared
sample point is now dropped when the two curves also coincide, to rounding, on
both sides of it. Where a coincident run starts or ends one side differs, and
the touch is kept.

* geom/hlr: the own-face margin allows for rounding where a body was modelled

The far 0.001 mm cube of the previous commit showed a second effect of records
rounded at large coordinates: its top plane, one ulp (1.9e-9 mm) above its
edges, hid the top edges themselves, because a ray leaving an edge met its own
face 3.2e-9 mm along, past the own-face margin of 1e-6 of the face (1.4e-9).
The margin now also covers 1e-14 of the body's offset (about 45 ulps of its
coordinates), which only matters for parts far smaller than their distance
from the origin.

* geom/hlr: no parity test for a hit on the boundary, where it is uncertain anyway

* docs/drawing-hlr: the fixtures of the review's second round

---------

Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
2026-10-27 00:20:17 -04:00
CamWight 3575c99750 Drawing W18 S2: title blocks as data (#3875) (#3877)
* Title blocks: what a cell shows and where its text goes (#3875)

geom::cell holds a title-block cell's content: parts of text and fields
(component, drawing, sheet, revision or prompt, by key, with an optional
unit and decimals kept as written for the resolver to check), at most 64
parts and 4096 bytes of text, with no control character but a line break.
Beside it are a resolved value (live, overridden and drifted, or missing,
with "-" for each part that couldn't be read and why), an override with
the value it replaced, and the projection a drawing's views use (first or
third angle), which the title block's symbol will read.

A fit warns and never cuts: a count of characters, not bytes, with its own
count for text in Chinese, Japanese, Korean or Yi characters.

The cell text rule places a label and a value: the label's baseline 1 mm
plus its cap height below the cell's top, the value box under it inset by
1 mm, one line centred in the box, and wrapped or several lines down from
its top at the font's pitch, with a warning when the last falls below the
box. Wrapping by width, width warnings and glyphs a font lacks need the
text measured, so they wait for the paper scene's text.

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

* Title blocks: definitions and their place on a sheet (#3875)

geom::title_block holds a title block's definition: its size (1 to 1000
mm a side), its outline and cell line widths, and up to 64 cells. Each cell
has a snake-case name of its own, a rectangle measured from the block's
lower-right corner, an optional one-line label with its cap height, and a
value: text from cell content, with its cap height, alignment and fit, or
the projection symbol, which reads the drawing's projection and is never
fixed in the block.

validate() checks a definition on its own, as when one is read: cells
inside the block and not overlapping (touching is fine), label and value
cap heights that fit, and the content's own limits. layout() puts the
block on the drawing border's lower-right corner of a sheet, refusing one
that doesn't fit, and gives each cell's rectangle, its label's place and
its value box by the cell text rule. On A3 landscape a 180 x 54 mm block
runs from (230, 10) to (410, 64); it fits A5 landscape and every larger
sheet, but not A5 upright.

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

* Cell text: a value box lies within the paper's coordinates (#3875)

place_lines took any finite, ordered value box, but centring one far out (min x 1e308, max x
1.1e308) overflows: the anchor came back infinite, with no warning. 0xlillith found it in review
of #3877.

The paper scene already bounds every coordinate it holds by MAX_COORDINATE_MM (twenty times
the longest sheet side). That limit is now public, and place_lines keeps its box inside it, so
its anchor is always finite: one rule for what a paper coordinate may be.

Tests: the hostile-box test refuses both overflow cases from the review and a box just past the
limit on each side, and places lines in a box the size of the whole paper limit. The fuzz test's
numbers now include values past the limit (1.1e5, ±1e308).

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

* Title blocks: padding from the lines' inner edges, one label rule, nothing unread (#3875)

A cell's text is padded from the inner edge of its lines: each side of a cell moves in by half
the line drawn on it (the outline's on the block's edges, a cell line's inside), so a wide
outline never covers the text. Before, the padding ran from the line's centre.

The label's one-line rule lives with the cell text rules (`cell::check_label`), where parts
lists will find it. The cell types nothing read yet (`Override`, `Resolved`, `State`,
`Problem`, `Reason` and `MISSING`) wait for the resolver that will read them.

The tests check the exact thresholds inside the lines (a label that leaves no room, a value
that just fits), pick each of the lines, alignment and wrapping on their own so every
combination comes up, and lay fuzzed blocks out on every standard sheet, either way, with and
without the filing margin.

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

* Title blocks: the drawing border's line, descenders, room where the block sits (#3875)

From DevonHope's review of #3877:
- Each cell's lines are clamped to the inner edge of the drawing border's line. That line runs
  along the block's right and bottom edges, and along any other edge it reaches, as when the
  block fills the border. Aabb2::intersection, the 2D twin of Aabb3's, does the clamping.
- layout checks each cell's room again where the block sits (place_cells, which validate runs
  at the origin), so rounding at the real corner can't turn a cell inside out.
- The cell text rule counts descenders, at 3/10 of the cap height (ISO 3098's lettering B).
  They may reach into the padding under a value box, never past it to a line. place_lines
  warns Height when they would, and validate asks it whether one line fits.
- The module doc no longer links to a private function.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:18:05 -04:00
CamWight de7bf4fc4d Drawing W17a: view definitions as data (#4816) (#4817)
* claim: #4816

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

* Drawing views as data: named views, scales and the map onto paper (#4816, W17a P1)

What a drawing view is, before anything draws it (geom::drawing_view):
- NamedView: the six principal views and the isometric views from the four upper corners, each
  an exact basis (a Plane whose normal points back at the viewer), never taken from a camera,
  whose pitch clamp would tilt a top view, with no -0 to write out; `iso` reads as the
  front-right one.
- basis_looking: any other view from its direction, an up and an origin (paper x = direction x up,
  paper y = x x direction), refusing a zero direction, an up along it, or an origin that isn't
  finite or within 1e12 mm. W07's
  {direction, up, origin} view reads through it.
- Scale: paper : model as two positive numbers, read and written as "1:2", from 1:100000 to
  1000:1; Scale::fit picks the largest ISO 5455 preset (50:1 to 1:10000) at which a view fits a
  space (W07's "fits the page" default), compared as size x paper <= room x model so an exact
  fit at a reduction isn't lost to rounding.
- ViewFrame: a view's basis, scale and place on paper, the point of its plane at `centre`
  landing at `position_mm`. Its paper map moves the view's curves and their accuracy onto
  paper (paper error = scale x model error); `boundary` maps a box of its plane onto paper from
  its four corners.

In geom, where Seg2 lives:
- Similarity2 and Seg2::similar (new): scale, turn and shift a segment exactly, an arc staying an
  arc. Similarity2::new refuses a scale that isn't above 0 and works the turn out once, not per
  point;
- Seg2::map_controls: a segment's control points mapped by any function. It was doc's
  sheet::map_controls; its four callers in doc use the method now, unchanged.

CurveAccuracy::scaled gives a curve's accuracy at another scale.

Tests: each named view's basis and viewer side, and the same bases from a direction and up; a 50 mm cube in every principal view at 1:1 and
1:2, and in isometric (edges 50 x sqrt(2/3), outline 50 x sqrt(2)); the view's centre on its
position (W07's 100 x 20 front view on A3 at 2:1); scales read, written and refused; the fitted
scale, an exact fit at 1:5 and ISO's smallest; no -0 in any named view's axes; an origin refused by
its own message; hostile bases, centres and positions; every segment kind moved by a similarity,
and the similarities refused; scaled accuracy.

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

* Drawing views: the bottom view pinned, square near parallel, bounded scales and boxes (#4816)

From DevonHope's review of #4817:
- The tests pin all six principal views exactly. The direction-and-up test uses literal ups,
  so a bottom view turned half round now fails it.
- basis_looking squares x to the direction before normalising. Near the 1e-9 threshold the
  normal was up to 8.8e-9 rad off; now it is about 1e-16.
- Scale::fit fits nothing for a size or room that is negative or not finite. A size of 0 still
  sets no limit that way.
- Each side of a scale is 1e-6 to 1e6, so a scale writes at most 49 characters.
- ViewFrame::boundary returns a Result. It refuses a box that is inside out or not finite on
  paper, instead of dropping a corner.
- paper_map, boundary and seg_to_paper say which coordinates they take.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:18:01 -04:00
CamWight f18616e19c Paper scene: SVG of a sheet, its lines, arcs, cubics and fills (#4890 A3a) (#7032)
The first export of a paper scene, as SVG first (jyviko and 0xlillith on #4890), from the research
in the plan's §16. A3b adds ellipses, hyperbolas and Béziers above degree 3, which this refuses as
"not supported yet".

- `geom::paper::Prepared`: writers read a scene through `Scene::prepare`, which checks it first, so
  none reads one unchecked. Clipping goes there too, once (A8).
- `geom::paper::arc_pieces`: an arc's ends in pieces of at most a quarter turn, the last its own end.
  A half turn drawn from its ends is ill-conditioned: rounded to the nanometre (six decimals of a millimetre) its
  centre moves up to 1e-2 mm, where a quarter turn's stays under 1e-6 mm.
- `io::paper::paper_svg`: one user unit a millimetre, the size in mm on the root; y flipped by
  writing the height less y, so the file's numbers are page coordinates and an anticlockwise arc
  has sweep flag 0; lines `L`, arcs `A` (never large-arc), cubics `C`, parabolas `Q`; fills
  black and even-odd; one path per item; groups as `data-object` and `data-kind` (object ids can't
  be XML ids). No background, so cutters see only the marks.
- Numbers are whole nanometres (six decimals of a millimetre): no exponent, no "-0", the same bytes every time. They are the
  writer's own state too, so a joint is written once and a closed path ends exactly on its first
  point. Numbers are written straight onto the document, with no text made per number. A width
  under half a nanometre is written as one, and a piece too short for the grid is merged into the
  next, both counted in the report; a path's last piece has no next, so it is kept however short
  (unless it has no length). Output over 64 MiB is refused.

Tests: the A3 drawing border's whole document; quarter, 270°, clockwise and whole-turn arcs, and a
circle of two halves giving the same four quarters; the quarter-turn boundary both sides; cubics and
parabolas, and the conics refused; the number format; a closed path whose start and end round
apart; a sliver merged; an open path's short last line and arc kept, and one of no length dropped; read back through our own SVG reader (lines exactly, the arc as an arc);
the scene's JSON mutated into the writer (never a panic).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:54:42 -04:00
CamWight 657c733166 Drawing files: a drawing saved as a container or as plain JSON, never read as a design (#3890) (#7098)
* File format families: drawings share the design format's versioning (#3890)

The format code works for a family of files: its own tag, its own numbers and
its own folder of change files, with one reading of a file's format, one
refusal of a file a newer build saved and one upgrade for every family. Designs
(solidcraft/N) are one family and keep everything they had: FORMAT and FORMATS
are as they were, and pending_names, format_string, version_of and read mean
what they did. Drawings (solidcraft-drawing/N) are the other, with format 1 as
their first record and each later change one file in
crates/doc/src/drawing/format/pending/, as design changes are. Nothing saved
changes.

A design tag never reads as a drawing's or the other way round, and a file that
names no format is a design from before format strings, never a drawing. Every
refusal names its family; a design's read word for word as before. A design
listing a pending change this build doesn't have is now refused as a newer
build's before its content is checked, as a newer number already was, so a
newer build's fields never read as damage. The list reader is
take_names(v, key, noun), the shape #4811 uses for its own list.

For the maintainer, the release step changes: scripts/fold-format-pending.sh
now folds every family that crates/doc/build.rs lists (FAMILIES), each into
its own next number: designs into crates/doc/src/format/v<N>/ as before,
drawings into crates/doc/src/drawing/format/v<N>/ from 2. With nothing pending
in a family it says so and moves nothing. Its test folds a drawing change into
v2 beside the design changes. docs/releasing.md, CONTRIBUTING.md and AGENTS.md say so.

build.rs lists each family's changes from its own folder, each folder's
versions contiguous from its own base.

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

* Drawing record: format 1 of the drawing family (#3890)

A drawing is now a record of its own (crates/doc/src/drawing.rs): its name,
the designs it draws (sources: an id and the design file's path, relative to
the drawing's folder as a linked design's is), its properties under the design
metadata's rules, its units (each design's own, or one length unit of the
expression unit table), its content language (a BCP 47 tag) and its sheets.
A sheet holds its paper, a copy of its drawing border's profile, the
resolution its pictures are rendered at (300 dpi unless it says, 25 to 2400),
the component it documents and whether it is left out of the sheet count or
of printing. This is format 1 of the drawing family; each later kind of
record adds one file in crates/doc/src/drawing/format/pending/.

The rules: one id counter for every object, raised above the largest id when
a file is read and never rewound; every reference is an id that must resolve,
or the file is damaged and refused, never patched; 1 to 1,000 sheets, at most
64 sources, names of 1 to 256 bytes, paths of 1 to 4,096 bytes without NUL.
The same drawing always saves as the same bytes.

Drawing::from_json reads at most 16 MiB of text, then runs the family's format
check, the number check, the upgrade, the typed record (unknown fields refused
everywhere) and Drawing::validate. The number check is now one function every
family calls, Family::check_numbers, told which field holds other tools' data;
a design's refusals read as before.

Nothing saves or opens a drawing yet: the container is the next step.

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

* Drawing ids: random and collision-free from format 1 (#3890)

A drawing's objects now take random ids, the form jyviko chose on #3890 from
#1017: no counter, so objects added on two branches never share an id and
never edit one line. The allocator is one module for drawings now and designs
later (#626 Phase 3), crates/doc/src/ids.rs: an IdSource gives nonzero u64s by
splitmix64 from a seed, each checked against the ids in use and drawn again
when it is one, or when it is 0. A run never draws a number twice, so at most
one draw per id in use comes before a free one, and a seeded run over a file
the same seed made still gets a new id. IdSource::seeded gives the same ids
for tests and replays; IdSource::from_entropy seeds a session from the
standard library's hash keys, the time and a count, with no new dependency.

In a file an id is 13 characters of Crockford base32, lowercase
(0000000000001 to fzzzzzzzzzzzz): ids::encode writes it and ids::decode reads
only that spelling, case-sensitive, so an id has one form to search for.
Anything else, 0 included, makes the drawing damaged. The drawing loses
next_id (a file that holds one is refused), and Drawing::new and allocate_id
take the IdSource. Messages name ids as the file writes them. The component a
sheet documents stays its design's own number until designs move over.

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

* Drawing record: a drawing too large to reopen isn't saved (#3890)

from_json reads at most MAX_DRAWING_BYTES (16 MiB), but to_json had no limit. Other tools'
properties are kept as they came, and indenting them can grow a drawing read in at about 4 MB
past 16 MiB (two million zeros in one array), so the next read refused what the save had just
written. 0xlillith found it in review of #3891.

to_json now returns a Result and refuses JSON longer than a read takes ("drawing too large to
save (at most 16 MiB)"), so every drawing saved opens again. Nothing writes drawing files yet, so
the error reaches the future file writer before it publishes anything.

Test: that case is refused, and a smaller compact drawing saves and reopens unchanged.

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

* Drawing files: a drawing saved as a container or as plain JSON, never read as a design (#3890)

K3 of the drawing storage plan (W11.2): the file a drawing is saved in. Nothing writes one from a
command yet; opening and saving come with K5.

- A drawing container is a design container holding `drawing.json` (Deflate, as `document.json`)
  in place of `document.json`, and `container.json` says so:
  {"content": "drawing", "format": "solidcraft-container/1"}. A design's `container.json` is as it
  was, with no `content`. The plain JSON form is the drawing's own text. Both give the same bytes
  every save, so a drawing can live in version control as text.
- `io::file_kind` tells a design from a drawing by `container.json` or by the JSON's format. The
  design reader refuses a drawing ("this file is a SolidCraft drawing, not a design: open it as a
  drawing") and the drawing reader a design; plain JSON only asks when it isn't a design, so
  reading a design costs nothing more. A container's content this build doesn't know, or a drawing
  container that also holds a design's `document.json`, is refused.
- `read_drawing` (bytes, either form), `read_drawing_file` (one seekable handle, the design
  reader's checks) and `write_drawing_container`; `DRAWING_EXT` (`scdwg`) and
  `is_drawing_path` beside the design's.
- The container writer's set-up and finish, the file opening and the container sniffing move into
  one function each, which designs and drawings both use. The container's own checks (budgets,
  entries, the ZIP directory) are the design container's, word for word.

Tests: a drawing round-trips through both forms byte for byte, and the container holds exactly
`container.json` and `drawing.json`; `file_kind` on both forms of both kinds; each reader refuses
the other's file; unknown content, a stray `document.json`, a missing drawing, an unknown
container version and a damaged drawing refused; both forms from disk, and a directory refused;
mutation fuzz of both forms (an error or a drawing, never a panic).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:18:29 -04:00
CamWight 4c4f25115b Bill of materials types: phantom, reference, purchased and inseparable (#3874) (#5107)
* Bill of materials types: phantom, reference, purchased and inseparable (#3874)

A component's bill of materials type is a part property (`bom_type` in its metadata, set with
component.metadata.set and read by component.metadata, as part_number is), so older releases
keep it and no format change is needed. It isn't a design property: the Design Properties
dialog doesn't list it and document.metadata.set refuses it. A word this build doesn't know
reads as normal and is kept.

assembly.bom counts by type: a phantom has no row and what it holds counts where it is placed,
in the same rows as the same parts there; a reference counts nowhere, and nothing inside it
does; purchased and inseparable components are one row, and in parts only an inseparable
component's purchased parts are listed too. Rows of one part number but different types are
different rows. Rows and children carry bom_type. With no types set the rows are as before.

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

* Glossary: bill of materials and bill of materials type (#3874)

The bill of materials types are a new idea, so the glossary gets their
term, with the five types, and "normal" gets its fourth meaning in the
table of words with more than one meaning.

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

* Bill of materials types: a plain line for each (#3874)

The guide's table and the glossary entry say what each type is for in
plain words: something you make, something just to group, something to
use as a reference, something you buy, something made as one piece.
The names stay the five standard ones.

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

* Bill of materials: kits fold together only when their contents' types match (#3874)

With group_children, two kits with one part number share a row when what they hold reads the
same. That reading (kit_key) compared each child by part number and count, but not by its bill
of materials type, though rows themselves are told apart by type. So a kit holding a made bolt
and a kit holding a bought one, both KIT, folded into one KIT x2 row listing the first kit's
bolt twice: the other type was lost, and which one depended on creation order. 0xlillith found
it in review of #5107.

kit_key now includes each child's type. Equal part number, type and contents still fold.

Test: two KIT kits each holding a BOLT fold into one row of two; once one bolt is purchased they
are two rows, each listing its own bolt. Planted fault (the type left out of the key): caught.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:05:56 -04:00
CamWight 672ef13eb8 Drawing sheets: no centring or trimming mark longer than the largest sheet's side (#3875) (#6410)
A border profile capped every length at 5000 mm (the largest sheet's side) but two: a
centring mark's reach inside the drawing border and a trimming mark's length. A file could
give either a huge whole number, such as 18446744073709551615. The read's number check passes
whole numbers, so the profile took it as 1.8e19 mm, and saving wrote it as a decimal the next
read refused: a drawing that saved but didn't open again. K2's fuzz test of drawing files
(#3890) found it.

Both now take the cap the margins have, `MAX_SIDE_MM`, in `validate`, so the file is refused
when it's read. Layout's own checks against the sheet are unchanged, so a 5000 mm mark still
validates and is refused only on a sheet too small for it.

Tests: the hostile-profile test refuses 5000.000001 mm and 1.8e19 mm for each mark, and takes
5000 mm on its own; 4 planted faults (each cap removed or made one short) are all caught.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:59:28 -04:00
CamWight 4c63a88fc3 Drawing sheets: centring marks across the whole margin (#3875) (#5554)
The built-in sheet's centring marks now run from the trimmed sheet's edge to the drawing border.
From the grid reference border, as before, a mark was the same length as a grid field mark, and on
A3 it sat on one (the sheet's centre falls on a field edge), so it no longer read as a mark once the
grid reference border's outline is left out (Cameron, after the prototype on #3875).

- DrawingBorderProfile.centring_mark_start: sheet_edge (the built-in's, and when not given) or
  grid_border (ISO 5457's figure 5); ISO's full border in the tests asks for grid_border.
- A mark still needs a length: one that stops on the drawing border needs room outside it on the
  side it starts from, every margin for sheet_edge, the grid reference border for grid_border.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:48:17 -04:00
CamWight 5e23f58f62 Paper scene: plain records of what is drawn on a sheet (#4890 A1) (#5513)
geom::paper holds what a sheet shows as plain data: groups of strokes and fills, each group tagged
with the object that drew it (an opaque id) and its kind, with one validation pass over a bounded
record count. Seg2's checks and the coordinate rule move into profile.rs so section and paper share
them, and drawing_sheet takes its side and line limits from paper, so each limit is written once.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:23:18 -04:00
Benny1496 01a542d6e8 Drawing W23a workshop plate oracle fixtures (#4950) (#4962)
* test(oracle): reviewer-aligned parameterized workshop plate fixtures (#4950)

Independent analytic W23 expected values and seven verified models,
including four R10 corners and the 2 mm tangent top chamfer.
Document accepted Drawing states and commit regenerated docs/oracle.md
from successful workflow run #29 (90/90 pass).

* fix(oracle): correct W23 chamfer centroid and clarify drawing session ownership
2026-10-23 07:35:28 -04:00
CamWight f0cc73eb83 Drawing sheets: sizes, the ISO 5457 border and its marks (#3875) (#5108)
* Drawing sheets: sizes and the ISO 5457 border (#3875)

A sheet is a paper size and an orientation: ISO A0 to A5, Letter, Legal,
ANSI A to F, architectural A to E1, or a custom size (finite, above 0 and
at most 5,000 mm a side), landscape by default and either way for every
size.

A border profile is the rule for its border, and layout() works it out
for a sheet: the frame inside the margins, and the reference fields with
their labels. The ISO profile has a 20 mm filing margin on the left and
10 mm elsewhere, a 0.7 mm frame, fields of 50 mm out from the centre
lines with the corner fields taking what's left, rows lettered from the
top without I and O (AA, AB and on past Z), columns numbered from the
left, and an A4 sheet labelled on its top and right only. A profile can
divide the frame equally instead, or have no grid.

The records sit in geom, next to the section records, so saving,
rendering and export can all use them. Nothing is saved or drawn yet:
there is no command and no format change. Margins that leave no drawing
space, values that aren't finite, fields outside 1 to 1000 mm, more than
200 equal fields and unknown JSON fields are refused.

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

* Drawing sheets: ISO 5457's centring and trimming marks (#3875)

A drawing border profile now also holds the grid reference border and the
centring and trimming marks, either of which can be left out. The ISO
profile follows ISO 5457:1999 (read in its identical adoption,
IS 10711:2001):
- the grid reference border 5 mm outside the drawing border on every
  side, with the field labels between the two;
- four centring marks on the sheet's centre lines, from the grid
  reference border to 10 mm inside the drawing border;
- at each corner a trimming mark of two overlapping 10 x 5 mm
  rectangles, in the margin outside the grid reference border.

The labelled sides are now a rule worked out for each sheet: ISO 5457's
(the top and right of A4, all four sides of the others) or fixed sides.
So one profile labels every size correctly. validate() checks what
doesn't depend on the sheet, for when a profile is read; layout() calls
it, then checks the rest against the sheet.

Refused:
- a grid reference border that isn't finite, is negative or is wider
  than a margin;
- a centring mark that isn't above 0, would reach past the sheet's
  centre lines, or is on a drawing border those lines miss;
- a trimming mark that isn't above 0, is wider than it is long, is
  wider than the margin left outside the grid reference border, or is
  longer than half the sheet's shorter side.

The grid rule's JSON is now externally tagged ("none",
{"from_centre": {"field_mm": 50}}), as the sheet size is, so an unknown
field is refused in every form; the internally tagged "none" ignored
one. Nothing is saved yet, so the shape change costs nothing.

The names follow the glossary, and ISO 5457's "drawing sheets": the
module is drawing_sheet (was sheet, which means sheet metal elsewhere
here), DrawingBorderProfile and SheetLayout (were BorderProfile and
BorderLayout), sheet_border and drawing_border (were paper and frame),
and Sheet::size_mm (was paper_mm). The first part of S1 is still a draft,
so the renames are made here rather than in a commit of their own.

The extra centring marks ISO 5457 asks for on sheets larger than A0
aren't here yet. Still nothing is saved or drawn.

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

* Drawing sheets: a lighter built-in border, the filing margin a choice (#3875)

Cameron's decisions after seeing the sheet in the app, 2026-10-23:
- No trimming marks in the built-in profile: the filled corner blocks
  take a lot of ink. trimming_mark_mm stays, so a profile can still ask
  for them.
- Centring marks stop at the drawing border: from the grid reference
  border to the drawing border, never into the drawing area (on A4 they
  crossed the tables). centring_mark_mm keeps its meaning, how far a
  mark reaches inside the drawing border, and now takes 0, the built-in
  value, as grid_border_mm already takes 0 for "on the drawing border".
  A mark of no length (a reach of 0 with no grid reference border) is
  refused.
- Margins of 10 mm all round. ISO 5457's 20 mm filing margin on the
  left, for drawings that get bound, is a named choice: filing_margin,
  off when not given. On, it stands in for margins.left_mm, which is
  kept, and still checked, for when it's off again. The 20 mm is
  FILING_MARGIN_MM, and validate() and layout() both take the margins
  from one helper, laid_out_margins().

A3 landscape is now 400 x 277 at (10, 10), with eight 50 mm columns.
With the filing margin it is 390 x 277 at (20, 10) again, the same as a
20 mm left margin set by hand. A profile with all three choices on
(ISO 5457's full border) lays out exactly as the built-in profile did
before, and the tests keep the old hand values for it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 03:01:08 -04:00
243 changed files with 34256 additions and 1964 deletions
Generated
+4
View File
@@ -4348,6 +4348,7 @@ dependencies = [
"gethostname",
"gltf",
"log",
"png",
"quick-xml",
"rustix 1.1.5",
"serde",
@@ -4356,7 +4357,9 @@ dependencies = [
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-kernel",
"solidcraft-sketch",
"thiserror 2.0.21",
"tiny-skia",
"zip",
]
@@ -4410,6 +4413,7 @@ dependencies = [
"harfrust",
"serde",
"serde_json",
"sha2 0.10.9",
"solidcraft-geom",
"thiserror 2.0.21",
"ttf-parser",
+6
View File
@@ -173,6 +173,12 @@ 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;
+4
View File
@@ -63,6 +63,10 @@ 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();
+3 -2
View File
@@ -458,12 +458,13 @@ fn snapshot_runs_its_script_on_the_in_design() {
}
}
/// An unknown command's error suggests the id that was meant.
/// An unknown command's error suggests the id that was meant first; other close ids (such as
/// `drawing.open`) may follow it.
#[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`"), "{}", String::from_utf8_lossy(&output.stderr));
}
/// #1833: `--timings` reports each command's time instead of the measurement, `--profile` adds
+6 -3
View File
@@ -681,11 +681,13 @@ mod web {
if let Some(res) = done {
let installed = match res {
Ok(bytes) => {
solidcraft_engine::sketch::fallback::provide_web_font(bytes.clone());
self.cjk_bytes = Some(bytes);
true
}
Err(err) => {
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
solidcraft_engine::sketch::fallback::web_font_failed();
false
}
};
@@ -720,10 +722,11 @@ mod web {
self.font_language.clone_from(&self.app.preferences.language);
}
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name):
/// fetch the font, so the text does not stay tofu (#720).
/// CJK text on screen with no CJK font yet (an English UI showing a Japanese name), or a
/// drawing's text waiting for one: fetch the font, so the text does not stay tofu (#720).
fn watch_text_for_font(&mut self, ctx: &egui::Context) {
if self.font.watching_text() && solidcraft_ui_egui::fonts::painted_cjk(ctx) && self.font.text_needs_font() {
let wanted = solidcraft_ui_egui::fonts::painted_cjk(ctx) || solidcraft_engine::sketch::fallback::web_font_wanted();
if self.font.watching_text() && wanted && self.font.text_needs_font() {
self.fetch_font(ctx);
}
}
@@ -178,6 +178,7 @@ pattern_rect false
perforate false
perspective false
pipe false
placeholder true
plane false
plane_along_path false
plane_angle false
@@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 962 B

@@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 846 B

@@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 651 B

@@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 851 B

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 420 B

@@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 730 B

@@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 631 B

@@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 1000 B

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 598 B

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 741 B

@@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 794 B

@@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 622 B

@@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 516 B

@@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 958 B

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 933 B

@@ -0,0 +1,7 @@
<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>

After

Width:  |  Height:  |  Size: 745 B

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 752 B

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 450 B

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 900 B

+4
View File
@@ -1928,6 +1928,9 @@
"prefs.max_file_size_step": "ملفات STEP والاستيراد",
"prefs.max_file_size_web": "يفتح تطبيق الويب ملفات STEP والاستيراد حتى 512 MB والتصاميم حتى 256 MB؛ لا يتسع تبويب المتصفح لملفات أكبر. يمكن رفع هذه الحدود في تطبيق سطح المكتب.",
"prefs.megabytes": "MB",
"prefs.menu_grid": "أيقونات كبيرة",
"prefs.menu_list": "قائمة",
"prefs.menus": "القوائم",
"prefs.minutes": "دقائق",
"prefs.minutes_suffix": " دقيقة",
"prefs.mouse": "الفأرة ولوحة اللمس",
@@ -4479,6 +4482,7 @@
"ui.revolve": "دوران",
"ui.ribbon_exit": "خروج",
"ui.ribbon_labels": "إظهار النص تحت أيقونات الشريط",
"ui.ribbon_menus_skip_pinned": "استبعاد الأوامر المثبتة من القوائم المنسدلة",
"ui.ribbon_pin_footer": "الأوامر المثبتة تظهر في الشريط. مرّر المؤشر على صف لتثبيته أو إلغاء تثبيته.",
"ui.ribbon_workspace": "مساحة العمل",
"ui.right": "يمين",
+11
View File
@@ -803,6 +803,8 @@
"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.body_rename": "Körper umbenennen",
"cmd.browser_collapse_all": "Alles einklappen",
"cmd.browser_expand_all": "Alles ausklappen",
@@ -975,6 +977,7 @@
"cmd.motion_trace": "Bewegungsbahn",
"cmd.motion_trace_edit": "Bewegungsbahn bearbeiten",
"cmd.motion_trace_keep": "Bewegungsbahn behalten",
"cmd.occurrence_bom_type": "Stücklistentyp",
"cmd.occurrence_copy": "Einfügen (Instanz)",
"cmd.occurrence_drag": "Instanz ziehen",
"cmd.occurrence_ground": "Festsetzen",
@@ -1817,6 +1820,7 @@
"prefs.dialogs_left_column": "In der linken Spalte",
"prefs.dialogs_note": "Wo Befehlsdialoge öffnen: am Rand der Ansicht angedockt oder in der linken Spalte anstelle von Browser und Verlauf, die beim Schließen des Dialogs wie zuvor zurückkehren.",
"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.",
@@ -1870,6 +1874,9 @@
"prefs.max_file_size_step": "STEP- und Importdateien",
"prefs.max_file_size_web": "Die Web-App öffnet STEP- und Importdateien bis 512 MB und Designs bis 256 MB: Ein Browser-Tab kann keine größeren Dateien aufnehmen. In der Desktop-App lassen sich diese Grenzen erhöhen.",
"prefs.megabytes": "MB",
"prefs.menu_grid": "Große Symbole",
"prefs.menu_list": "Liste",
"prefs.menus": "Menüs",
"prefs.minutes_suffix": " min",
"prefs.origin_plane_names": "Namen der Ursprungsebenen",
"prefs.origin_plane_names_note": "Namen in Browser, Dialogen und Listen. {top} ist XY, {front} ist XZ und {right} ist YZ, mit Z nach oben. Befehle, Skripte und gespeicherte Konstruktionen behalten XY, XZ und YZ.",
@@ -3477,6 +3484,7 @@
"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",
@@ -3735,6 +3743,7 @@
"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",
@@ -3790,6 +3799,7 @@
"ui.revolve": "Drehen",
"ui.ribbon_exit": "Beenden",
"ui.ribbon_labels": "Text unter den Symbolen im Menüband anzeigen",
"ui.ribbon_menus_skip_pinned": "Angeheftete Befehle nicht in den Aufklappmenüs wiederholen",
"ui.ribbon_pin_footer": "Angeheftete Befehle erscheinen im Menüband. Fahren Sie über eine Zeile, um sie anzuheften oder zu lösen.",
"ui.ribbon_workspace": "Arbeitsbereich",
"ui.right": "Rechts",
@@ -3890,6 +3900,7 @@
"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",
+41
View File
@@ -819,6 +819,8 @@
"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.body_rename": "Rename Body",
"cmd.browser_collapse_all": "Collapse All",
"cmd.browser_expand_all": "Expand All",
@@ -909,6 +911,36 @@
"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",
@@ -993,6 +1025,7 @@
"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",
@@ -1870,6 +1903,7 @@
"prefs.dialogs_left_column": "In the left column",
"prefs.dialogs_note": "Where command dialogs open: docked at the view's edge, or in the left column in place of the browser and the History, which come back as they were when the dialog closes.",
"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.",
@@ -1928,6 +1962,9 @@
"prefs.max_file_size_step": "STEP and import files",
"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.menu_grid": "Large icons",
"prefs.menu_list": "List",
"prefs.menus": "Menus",
"prefs.minutes": "minutes",
"prefs.minutes_suffix": " min",
"prefs.mouse": "Mouse and trackpad",
@@ -4137,6 +4174,7 @@
"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",
@@ -4421,6 +4459,7 @@
"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",
@@ -4479,6 +4518,7 @@
"ui.revolve": "Revolve",
"ui.ribbon_exit": "Exit",
"ui.ribbon_labels": "Show text under ribbon icons",
"ui.ribbon_menus_skip_pinned": "Leave pinned commands out of the drop-down menus",
"ui.ribbon_pin_footer": "Pinned commands show on the ribbon. Hover a row to pin or unpin it.",
"ui.ribbon_workspace": "Workspace",
"ui.right": "Right",
@@ -4595,6 +4635,7 @@
"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",
+4
View File
@@ -1928,6 +1928,9 @@
"prefs.max_file_size_step": "קובצי STEP וייבוא",
"prefs.max_file_size_web": "יישום הדפדפן פותח קובצי STEP וייבוא עד 512 MB וקובצי תכן עד 256 MB: לשונית דפדפן אינה יכולה להכיל קבצים גדולים יותר. ביישום למחשב ניתן להעלות מגבלות אלה.",
"prefs.megabytes": "MB",
"prefs.menu_grid": "סמלים גדולים",
"prefs.menu_list": "רשימה",
"prefs.menus": "תפריטים",
"prefs.minutes": "דקות",
"prefs.minutes_suffix": " דק'",
"prefs.mouse": "עכבר ומשטח מגע",
@@ -4479,6 +4482,7 @@
"ui.revolve": "סיבוב",
"ui.ribbon_exit": "יציאה",
"ui.ribbon_labels": "הצגת טקסט מתחת לסמלי רצועת הכלים",
"ui.ribbon_menus_skip_pinned": "השמטת פקודות מוצמדות מהתפריטים הנפתחים",
"ui.ribbon_pin_footer": "פקודות מוצמדות מוצגות ברצועת הכלים. יש לרחף מעל שורה כדי להצמיד אותה או לבטל את הצמדתה.",
"ui.ribbon_workspace": "סביבת עבודה",
"ui.right": "ימין",
+41
View File
@@ -819,6 +819,8 @@
"cmd.appearance_pick_up": "外観を取得",
"cmd.appearance_replace": "外観を置換",
"cmd.assembly_bom": "部品表",
"cmd.assembly_bom_item_set": "項目番号の変更",
"cmd.assembly_bom_renumber": "項目番号の振り直し",
"cmd.body_rename": "ボディの名前を変更",
"cmd.browser_collapse_all": "すべて折りたたむ",
"cmd.browser_expand_all": "すべて展開",
@@ -909,6 +911,36 @@
"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": "チェックポイントを復元",
@@ -993,6 +1025,7 @@
"cmd.motion_trace_edit": "モーション トレースを編集",
"cmd.motion_trace_keep": "モーション トレースを保持",
"cmd.occurrence_align": "オカレンスを整列",
"cmd.occurrence_bom_type": "部品表の種類",
"cmd.occurrence_copy": "貼り付け(インスタンス)",
"cmd.occurrence_drag": "オカレンスをドラッグ",
"cmd.occurrence_ground": "空間固定",
@@ -1870,6 +1903,7 @@
"prefs.dialogs_left_column": "左の列",
"prefs.dialogs_note": "コマンドダイアログを開く場所:ビューの端にドッキングするか、左の列でブラウザーと履歴の代わりに表示します。ダイアログを閉じるとブラウザーと履歴は元どおりに戻ります。",
"prefs.display": "表示",
"prefs.drawings": "図面",
"prefs.edges": "エッジと陰影",
"prefs.extend": "拡張",
"prefs.extra_navigation": "何もない場所でCtrl+左ドラッグすると回転し、Spaceを押しながらドラッグすると移動します。",
@@ -1928,6 +1962,9 @@
"prefs.max_file_size_step": "STEP・インポートファイル",
"prefs.max_file_size_web": "ウェブ版で開けるのは STEP・インポートファイルが 512 MB、デザインが 256 MB までです。ブラウザーのタブはそれ以上のファイルを保持できません。デスクトップ版では上限を上げられます。",
"prefs.megabytes": "MB",
"prefs.menu_grid": "大きなアイコン",
"prefs.menu_list": "リスト",
"prefs.menus": "メニュー",
"prefs.minutes": "分",
"prefs.minutes_suffix": " min",
"prefs.mouse": "マウスとトラックパッド",
@@ -4137,6 +4174,7 @@
"ui.fine": "細かい",
"ui.finest": "最も細かい",
"ui.finish_sketch": "スケッチを終了",
"ui.first_angle": "第一角法",
"ui.fit": "フィット",
"ui.fix_unfix": "固定/固定解除",
"ui.flat": "展開",
@@ -4421,6 +4459,7 @@
"ui.profiles": "プロファイル",
"ui.project": "投影",
"ui.project_face_edges_sketch": "新規スケッチに面のエッジを自動投影",
"ui.projection_of_new_drawings": "新規図面の投影法",
"ui.properties": "プロパティ",
"ui.properties.copy_matrix": "行列をコピー",
"ui.properties.first_moments": "一次モーメント",
@@ -4479,6 +4518,7 @@
"ui.revolve": "回転",
"ui.ribbon_exit": "終了",
"ui.ribbon_labels": "リボンのアイコンの下にテキストを表示",
"ui.ribbon_menus_skip_pinned": "ピン留めしたコマンドをドロップダウンメニューに表示しない",
"ui.ribbon_pin_footer": "ピン留めしたコマンドはリボンに表示されます。行にポインターを合わせて、ピン留めや解除ができます。",
"ui.ribbon_workspace": "ワークスペース",
"ui.right": "右側面",
@@ -4595,6 +4635,7 @@
"ui.text": "テキスト",
"ui.theme": "テーマ",
"ui.thickness": "厚さ",
"ui.third_angle": "第三角法",
"ui.thread": "ねじ",
"ui.tie_ends": "拘束",
"ui.tie_ends_tip": "未拘束をすべて拘束",
+7
View File
@@ -711,6 +711,8 @@
"cmd.appearance_pick_up": "모양 가져오기",
"cmd.appearance_replace": "모양 바꾸기",
"cmd.assembly_bom": "자재 명세서",
"cmd.assembly_bom_item_set": "항목 번호 변경",
"cmd.assembly_bom_renumber": "항목 번호 다시 매기기",
"cmd.body_rename": "바디 이름 변경",
"cmd.browser_group": "새 그룹",
"cmd.browser_move": "브라우저 항목 이동",
@@ -870,6 +872,7 @@
"cmd.motion_study": "운동 연구",
"cmd.motion_trace": "모션 궤적",
"cmd.motion_trace_keep": "모션 궤적 유지",
"cmd.occurrence_bom_type": "자재 명세서 유형",
"cmd.occurrence_copy": "붙여넣기(인스턴스)",
"cmd.occurrence_ground": "고정",
"cmd.occurrence_move": "인스턴스 이동",
@@ -1552,6 +1555,7 @@
"prefs.dialogs_left_column": "왼쪽 열",
"prefs.dialogs_note": "명령 대화 상자가 열리는 위치: 뷰 가장자리에 고정하거나, 왼쪽 열에서 브라우저와 이력 대신 표시합니다. 대화 상자를 닫으면 브라우저와 이력이 그대로 돌아옵니다.",
"prefs.display": "표시",
"prefs.drawings": "도면",
"prefs.edges": "모서리 및 음영",
"prefs.extend": "확장",
"prefs.extra_navigation": "Ctrl+왼쪽 드래그로 빈 공간에서 회전하고, Space를 누른 채 이동합니다.",
@@ -3222,6 +3226,7 @@
"ui.fine": "세밀",
"ui.finest": "가장 세밀",
"ui.finish_sketch": "스케치 종료",
"ui.first_angle": "제1각법",
"ui.fit": "맞춤",
"ui.fix_unfix": "고정/고정 해제",
"ui.flat": "플랫",
@@ -3482,6 +3487,7 @@
"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차 모멘트",
@@ -3636,6 +3642,7 @@
"ui.text": "텍스트",
"ui.theme": "테마",
"ui.thickness": "두께",
"ui.third_angle": "제3각법",
"ui.thread": "나사산",
"ui.tie_ends": "연결",
"ui.tie_ends_tip": "연결되지 않은 모든 끝점 구속",
@@ -0,0 +1,26 @@
{
"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": "绘图状态"
}
}
@@ -0,0 +1,26 @@
{
"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": "投影视图"
}
}
@@ -0,0 +1,834 @@
{
"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": "뷰"
}
}
@@ -0,0 +1,119 @@
{
"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,50 @@
{
"en": {
"prefs.viewport_analysis_status": "Show viewport analysis status",
"prefs.viewport_analysis_status_help": "Show active analyses above the viewport, open their legends from each item, and use Esc to turn off the newest analysis. Turn off to restore corner legends and the previous Esc behaviour.",
"ui.viewport_status_edges": "{count} edges",
"ui.viewport_status_from": "from {direction}"
},
"ar": {
"prefs.viewport_analysis_status": "إظهار حالة التحليل في منفذ العرض",
"prefs.viewport_analysis_status_help": "عرض التحليلات النشطة أعلى منفذ العرض وفتح مفتاح كل تحليل من عنصره. ينهي Esc أحدث تحليل. أوقف الخيار لاستعادة المفاتيح في الزاوية وسلوك Esc السابق.",
"ui.viewport_status_edges": "{count} حواف",
"ui.viewport_status_from": "من {direction}"
},
"de": {
"prefs.viewport_analysis_status": "Analysestatus im Ansichtsfenster anzeigen",
"prefs.viewport_analysis_status_help": "Zeigt aktive Analysen über dem Ansichtsfenster und ihre Legenden am jeweiligen Eintrag. Esc beendet die neueste Analyse. Ausschalten stellt Ecklegenden und das bisherige Esc-Verhalten wieder her.",
"ui.viewport_status_edges": "{count} Kanten",
"ui.viewport_status_from": "aus {direction}"
},
"he": {
"prefs.viewport_analysis_status": "הצגת מצב ניתוח בחלון התצוגה",
"prefs.viewport_analysis_status_help": "הצגת ניתוחים פעילים מעל חלון התצוגה ופתיחת המקרא מכל פריט. Esc מכבה את הניתוח האחרון. כיבוי האפשרות משחזר את המקרא בפינה ואת פעולת Esc הקודמת.",
"ui.viewport_status_edges": "{count} קצוות",
"ui.viewport_status_from": "מכיוון {direction}"
},
"ja": {
"prefs.viewport_analysis_status": "ビューポートの解析状態を表示",
"prefs.viewport_analysis_status_help": "有効な解析をビューポートの上部に表示し、各項目から凡例を開きます。Esc で最新の解析を解除します。無効にすると隅の凡例と従来の Esc 動作に戻ります。",
"ui.viewport_status_edges": "{count} エッジ",
"ui.viewport_status_from": "方向 {direction}"
},
"ko": {
"prefs.viewport_analysis_status": "뷰포트 해석 상태 표시",
"prefs.viewport_analysis_status_help": "활성 해석을 뷰포트 위에 표시하고 각 항목에서 범례를 엽니다. Esc로 최신 해석을 끕니다. 끄면 모서리 범례와 이전 Esc 동작으로 돌아갑니다.",
"ui.viewport_status_edges": "모서리 {count}개",
"ui.viewport_status_from": "{direction} 방향에서"
},
"vi": {
"prefs.viewport_analysis_status": "Hiện trạng thái phân tích trong khung nhìn",
"prefs.viewport_analysis_status_help": "Hiện các phân tích đang bật phía trên khung nhìn và mở chú giải từ từng mục. Esc tắt phân tích mới nhất. Tắt tùy chọn để khôi phục chú giải ở góc và hành vi Esc trước đây.",
"ui.viewport_status_edges": "{count} cạnh",
"ui.viewport_status_from": "từ {direction}"
},
"zh": {
"prefs.viewport_analysis_status": "显示视口分析状态",
"prefs.viewport_analysis_status_help": "在视口上方显示启用的分析,从各项目打开图例,并按 Esc 关闭最近启用的分析。关闭此选项可恢复角落图例和原来的 Esc 行为。",
"ui.viewport_status_edges": "{count} 条边",
"ui.viewport_status_from": "从 {direction}"
}
}
+4
View File
@@ -1928,6 +1928,9 @@
"prefs.max_file_size_step": "Tệp STEP và tệp nhập",
"prefs.max_file_size_web": "Ứng dụng web mở tệp STEP và tệp nhập tối đa 512 MB, thiết kế tối đa 256 MB: thẻ trình duyệt không chứa được tệp lớn hơn. Ứng dụng máy tính cho phép tăng các giới hạn này.",
"prefs.megabytes": "MB",
"prefs.menu_grid": "Biểu tượng lớn",
"prefs.menu_list": "Danh sách",
"prefs.menus": "Menu",
"prefs.minutes": "phút",
"prefs.minutes_suffix": " phút",
"prefs.mouse": "Chuột và bàn di cảm ứng",
@@ -4479,6 +4482,7 @@
"ui.revolve": "Xoay tròn tạo khối",
"ui.ribbon_exit": "Thoát",
"ui.ribbon_labels": "Hiển thị văn bản bên dưới biểu tượng dải băng",
"ui.ribbon_menus_skip_pinned": "Không lặp lại các lệnh đã ghim trong menu thả xuống",
"ui.ribbon_pin_footer": "Các lệnh đã ghim hiển thị trên ruy-băng. Rê chuột lên một hàng để ghim hoặc bỏ ghim.",
"ui.ribbon_workspace": "Không gian làm việc",
"ui.right": "Phải (Hình chiếu cạnh)",
+41
View File
@@ -819,6 +819,8 @@
"cmd.appearance_pick_up": "拾取外观",
"cmd.appearance_replace": "替换外观",
"cmd.assembly_bom": "物料清单",
"cmd.assembly_bom_item_set": "更改项目编号",
"cmd.assembly_bom_renumber": "重新编号项目",
"cmd.body_rename": "重命名实体",
"cmd.browser_collapse_all": "全部折叠",
"cmd.browser_expand_all": "全部展开",
@@ -909,6 +911,36 @@
"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": "恢复检查点",
@@ -993,6 +1025,7 @@
"cmd.motion_trace_edit": "编辑运动轨迹",
"cmd.motion_trace_keep": "保留运动轨迹",
"cmd.occurrence_align": "对齐实例",
"cmd.occurrence_bom_type": "物料清单类型",
"cmd.occurrence_copy": "粘贴(实例)",
"cmd.occurrence_drag": "拖动实例",
"cmd.occurrence_ground": "固定",
@@ -1870,6 +1903,7 @@
"prefs.dialogs_left_column": "左侧栏",
"prefs.dialogs_note": "命令对话框的打开位置:停靠在视图边缘,或在左侧栏中替代浏览器和历史;关闭对话框后,浏览器和历史恢复原样。",
"prefs.display": "显示",
"prefs.drawings": "绘图",
"prefs.edges": "边缘与着色",
"prefs.extend": "扩展",
"prefs.extra_navigation": "在空白处按住Ctrl并用鼠标左键拖动可旋转;按住空格键可平移。",
@@ -1928,6 +1962,9 @@
"prefs.max_file_size_step": "STEP 和导入文件",
"prefs.max_file_size_web": "网页版可打开最大 512 MB 的 STEP 和导入文件,以及最大 256 MB 的设计:浏览器标签页无法容纳更大的文件。桌面版可提高这些上限。",
"prefs.megabytes": "MB",
"prefs.menu_grid": "大图标",
"prefs.menu_list": "列表",
"prefs.menus": "菜单",
"prefs.minutes": "分钟",
"prefs.minutes_suffix": " min",
"prefs.mouse": "鼠标与触控板",
@@ -4137,6 +4174,7 @@
"ui.fine": "精细",
"ui.finest": "最精细",
"ui.finish_sketch": "完成草图",
"ui.first_angle": "第一角投影",
"ui.fit": "适合",
"ui.fix_unfix": "固定/取消固定",
"ui.flat": "平面",
@@ -4421,6 +4459,7 @@
"ui.profiles": "轮廓",
"ui.project": "投影",
"ui.project_face_edges_sketch": "将面的边投影到新草图",
"ui.projection_of_new_drawings": "新绘图的投影法",
"ui.properties": "属性",
"ui.properties.copy_matrix": "复制矩阵",
"ui.properties.first_moments": "一阶矩",
@@ -4479,6 +4518,7 @@
"ui.revolve": "旋转",
"ui.ribbon_exit": "退出",
"ui.ribbon_labels": "显示功能区图标下的文字",
"ui.ribbon_menus_skip_pinned": "下拉菜单中不再列出已固定的命令",
"ui.ribbon_pin_footer": "固定的命令显示在功能区中。将指针悬停在行上可固定或取消固定。",
"ui.ribbon_workspace": "工作区",
"ui.right": "右视",
@@ -4595,6 +4635,7 @@
"ui.text": "文本",
"ui.theme": "主题",
"ui.thickness": "厚度",
"ui.third_angle": "第三角投影",
"ui.thread": "螺纹",
"ui.tie_ends": "约束",
"ui.tie_ends_tip": "约束全部未约束端点",
@@ -0,0 +1,22 @@
| 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 |
+96 -9
View File
@@ -28,6 +28,18 @@ 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],
@@ -44,6 +56,11 @@ 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)
}
@@ -118,6 +135,10 @@ 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 {
@@ -126,7 +147,8 @@ 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`].
/// without one is [`BomType::Normal`]. One occurrence can count as another type
/// ([`Occurrence::bom_type`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BomType {
@@ -170,21 +192,16 @@ 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),
PlaneRef::Custom { plane } => *plane = map_plane(m, plane).unwrap_or(*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);
*plane = map_plane(m, plane).unwrap_or(*plane);
*at = apply_point(m, *at);
}
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => map_plane_ref(m, base),
@@ -302,6 +319,22 @@ 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))
@@ -352,6 +385,45 @@ 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.
@@ -418,6 +490,21 @@ 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
}
/// Would putting a placement of `component` into `parent` put it inside itself: is `parent`
/// the component, or inside it, created in it or placed in it at any depth? Placements can sit
/// apart from where their component was created ([`Self::component_within`] follows only
@@ -457,7 +544,7 @@ impl Document {
self.next_id = id.checked_add(1).ok_or_else(|| DocError::Invalid("too many items in the design".into()))?;
let cname = self.components.iter().find(|c| c.id == component).map(|c| c.name.clone()).unwrap_or_default();
let k = self.occurrences.iter().filter(|o| o.component == component).count() + 1;
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false });
self.occurrences.push(crate::Occurrence { id, component, parent, name: format!("{cname}:{k}"), transform, grounded: false, bom_type: None });
Ok(id)
}
}
+924
View File
@@ -0,0 +1,924 @@
//! 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, their members and `row_of`
//! stay inside this crate until balloons read them; 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)]
#[cfg_attr(not(test), expect(dead_code, reason = "item numbers and balloons follow rows by them (#3874)"))]
pub(crate) 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`).
#[cfg_attr(not(test), expect(dead_code, reason = "item numbers and balloons follow rows by them (#3874)"))]
pub(crate) fn row_of(&self, tree: &Tree, path: &[u64]) -> std::result::Result<&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.
fn row_at(&self, at: &[usize]) -> Option<&Row> {
let (first, rest) = at.split_first()?;
let mut row = self.rows.get(*first)?;
for &i in rest {
row = row.children.get(i)?;
}
Some(row)
}
/// 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)
}
/// 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.
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) => 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))
}
/// 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(crate) 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
@@ -0,0 +1,260 @@
//! 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
@@ -0,0 +1,101 @@
//! 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()),
}
}
+893
View File
@@ -0,0 +1,893 @@
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<&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));
}
+6 -3
View File
@@ -11,6 +11,9 @@ pub(crate) fn shell_plane_family(planes: impl IntoIterator<Item = Option<solidcr
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 {
@@ -37,7 +40,7 @@ impl ConfigFaceTarget {
if self.body.is_empty()
|| self.body.len() > 128
|| self.name.is_empty()
|| self.name.len() > 512
|| self.name.len() > MAX_REFERENCE_NAME_BYTES
|| self.origin_feature == 0
|| [self.point.x, self.point.y, self.point.z].iter().any(|v| !v.is_finite() || v.abs() > 1e9)
{
@@ -52,7 +55,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() > 512)
|| fields.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > MAX_REFERENCE_NAME_BYTES)
|| 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))
@@ -90,7 +93,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() > 512)
|| face.get("name").and_then(Value::as_str).is_none_or(|name| name.is_empty() || name.len() > MAX_REFERENCE_NAME_BYTES)
|| 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))
}))
+25 -10
View File
@@ -204,7 +204,7 @@ fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)
}
/// The most edge names a vertex reference keeps.
const MAX_VERTEX_EDGES: usize = 16;
pub(crate) 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.
@@ -227,17 +227,32 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
})
})
.collect::<Option<_>>()?;
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)))
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
}
/// A polyline that is straight: its line.
fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
pub(crate) 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();
@@ -245,7 +260,7 @@ fn as_line(pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
}
/// A polyline on a circle: (centre, unit normal, radius).
fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
pub(crate) fn as_circle(pts: &[Vec3]) -> Option<(Vec3, Vec3, f64)> {
let n = pts.len();
if n < 4 {
return None;
+34 -15
View File
@@ -1,30 +1,31 @@
//! The existing length display policy, shared by windowed and headless consumers.
//!
//! Values are stored in millimetres and displayed in the five design units. Unknown unit
//! names retain the millimetre fallback; expression units have a separate, broader vocabulary.
//! These functions format values, not validate them: non-finite inputs retain Rust's `NaN`,
//! `inf` and `-inf` text. Callers that require finite measurements must validate them first.
//! 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.
use serde::{Deserialize, Serialize};
use crate::expr::{Kind, UNITS};
use crate::{DocError, Result, expr};
/// Units per millimetre and the unit's name. Unknown names use millimetres.
/// Units per millimetre and the unit's name, for a length unit of [`UNITS`]. Other names use
/// millimetres.
pub fn unit_scale(units: &str) -> (f64, &'static str) {
match units {
"cm" => (0.1, "cm"),
"m" => (0.001, "m"),
"in" => (1.0 / 25.4, "in"),
"ft" => (1.0 / 304.8, "ft"),
_ => (1.0, "mm"),
match UNITS.iter().find(|(name, kind, _)| *name == units && *kind == Kind::Length) {
Some(&(name, _, mm)) => (1.0 / mm, name),
None => (1.0, "mm"),
}
}
/// Inch labels retain a meaningful fourth digit without promoting floating-point noise.
/// `value` is already converted to the requested display unit, not a millimetre value.
/// 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.
pub fn length_precision(value: f64, units: &str, decimals: u8) -> usize {
let base = usize::from(decimals.min(8));
if units != "in" {
if unit_scale(units).0 != unit_scale("in").0 {
return base;
}
let needs_four = ((value * 10_000.0).round() - (value * 1000.0).round() * 10.0).abs() >= 0.5;
@@ -182,6 +183,7 @@ 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() {
@@ -245,9 +247,26 @@ 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() {
for units in ["", "inch", " in ", "IN", "unsupported"] {
// Names that aren't a length unit of the expression table, angles included.
for units in ["", " in ", "IN", "unsupported", "deg", "rad"] {
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}");
+77 -23
View File
@@ -468,12 +468,7 @@ impl EmbossFace {
/// Resolve this exact placement without expanding every assembly instance.
pub fn body_frame(&self, doc: &Document, body: &crate::ModelBody) -> Result<crate::Mat> {
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)
doc.body_frame_at(body, &self.path)
}
/// Resolve one saved component path without expanding other occurrences.
@@ -2768,6 +2763,12 @@ 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
@@ -2993,6 +2994,7 @@ impl Document {
browser_order: Default::default(),
named_views: Vec::new(),
visibility: Visibility::default(),
drawings: Vec::new(),
}
}
@@ -3128,21 +3130,28 @@ impl Document {
/// This design with `other`'s part properties, or `None` when they are this design's
/// already (then nothing is copied). The part properties are the fields that change no
/// geometry: the design's metadata, and each component's metadata and free properties. A
/// field that joins them is compared and copied here, and nowhere else.
/// 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.
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.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;
if same {
return None;
}
let mut out = self.clone();
out.metadata.clone_from(&other.metadata);
// Paired by position: if the components themselves differ, so do the designs.
out.assembly.item_numbers.clone_from(&other.assembly.item_numbers);
// Paired by position: if the components or placements 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)
}
@@ -3158,6 +3167,32 @@ 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`.)
@@ -3268,12 +3303,12 @@ impl Document {
}
pub fn legacy_body_component(&self, body: &str, feature: u64) -> u64 {
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
self.legacy_body_component_with(body, feature, |f| self.feature(f).map(|f| f.component))
}
/// Ownership in this evaluated timeline state, rather than a later transfer.
pub fn model_body_component(&self, body: &crate::ModelBody) -> u64 {
if body.transferred || body.source_name.is_some() { body.component } else { self.legacy_body_component(&body.name, body.feature) }
self.model_body_component_with(body, |f| self.feature(f).map(|f| f.component))
}
/// [`Document::model_body_component`] of every body of `state`, in its order, with the
@@ -3298,17 +3333,25 @@ 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| {
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()
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))
}
/// Recorded transfers already converted geometry into the new local frame.
@@ -3316,6 +3359,17 @@ 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) {
+278 -43
View File
@@ -1,11 +1,14 @@
//! 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.
//! 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.
//!
//! - **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.
//! - 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, 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
@@ -13,6 +16,15 @@
//! - **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`]).
//! - **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
@@ -20,35 +32,47 @@
//! 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 and at most [`MAX_SOURCES`] sources; names of 1 to
//! [`MAX_NAME_BYTES`] bytes; paths of 1 to [`MAX_PATH_BYTES`] bytes, without NUL.
//! - 1 to [`MAX_SHEETS`] sheets; names of 1 to [`MAX_NAME_BYTES`] bytes.
//! - Nothing derived is saved, and a drawing always saves as the same bytes: fields in their
//! order, maps sorted.
//! 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.
//! - A later change to the record is one file in `src/drawing/format/pending/` (its README).
//!
//! [`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`].
//! [`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 and balloons by id, then [`Drawing::validate`]; [`Drawing::from_value`] the same from the
//! format check on.
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solidcraft_geom::cell::Projection;
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
use solidcraft_geom::revision::RevisionRecord;
use solidcraft_geom::{Aabb2, Vec2};
use crate::format::DRAWING;
use crate::ids::{self, IdSource};
use crate::{DocError, Metadata, Result, expr};
pub mod balloon;
pub mod dimension;
pub mod view;
use balloon::Balloon;
use dimension::Dimension;
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.
@@ -72,29 +96,49 @@ 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.
/// The designs the drawing draws: for now at most one, the design it is in.
pub sources: Vec<Source>,
/// The drawing's properties and other tools' data, kept as they are.
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub properties: Metadata,
/// 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>,
#[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>>,
/// By id ([`Drawing::add_balloons`]), as the dimensions are.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub balloons: Vec<Arc<Balloon>>,
/// 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>,
}
/// A design the drawing draws.
/// 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.
#[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.
@@ -149,48 +193,98 @@ 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)? }),
}
}
}
/// 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>,
}
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()
}
}
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 from `id_source`.
/// `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').
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(&BTreeSet::new())?;
let first = Sheet { id, name: sheet.into(), sheet: paper, border, dpi: DEFAULT_DPI, documents: None, excluded: Excluded::default() };
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, 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: Metadata::default(),
properties: Arc::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: Vec::new(),
revisions: Arc::default(),
};
drawing.validate()?;
Ok(drawing)
}
/// Read a drawing file's JSON (any format this build knows; older ones are upgraded). A file
/// Read a drawing's JSON (any format this build knows; older ones are upgraded). A drawing
/// 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 mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
let v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
Drawing::from_value(v)
}
/// 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)?;
DRAWING.check_numbers(&v, "properties")?;
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
let mut d: Drawing = serde_json::from_value(v).map_err(damaged)?;
let (views, dimensions, balloons) = (std::mem::take(&mut d.views), std::mem::take(&mut d.dimensions), std::mem::take(&mut d.balloons));
d.add_views(views);
d.add_dimensions(dimensions);
d.add_balloons(balloons);
d.validate().map_err(damaged)?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
@@ -211,21 +305,78 @@ 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.balloon(id).is_some()
}
/// What is on sheet `id`: its views and the dimensions and balloons in them. 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.
self.with_contents(self.views.iter().filter(|v| v.sheet == id).map(|v| v.id).collect())
}
/// `views`, listed by id, with the dimensions and balloons in them.
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();
OnSheet { views, dimensions, balloons }
}
/// 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. 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());
}
/// 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> {
self.sources.iter().map(|s| s.id).chain(self.sheets.iter().map(|s| s.id))
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.revisions.ids())
}
/// The record's rules (the module's notes): what reading a file checks, and what every edit
/// The record's rules (the module's notes): what reading a drawing 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() <= MAX_SOURCES, || format!("a drawing draws at most {MAX_SOURCES} designs"))?;
require(self.sources.len() <= 1, || "a drawing draws only the design it is in".into())?;
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);
@@ -236,24 +387,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))))?;
}
Ok(())
// 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()))?;
self.check_views()?;
self.check_dimensions()?;
self.check_balloons()
}
/// 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)?;
// The border must lay out on the sheet: the one rule, as drawing it uses it. A few small
// lists per sheet, so every edit's check stays cheap.
if let Some(border) = &sheet.border {
border.validate().map_err(from_sheet)?;
border.layout(&sheet.sheet).map_err(from_sheet)?;
}
require((MIN_DPI..=MAX_DPI).contains(&sheet.dpi), || format!("its pictures are {MIN_DPI} to {MAX_DPI} dpi"))?;
match sheet.documents {
@@ -264,6 +416,89 @@ 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.
+133
View File
@@ -0,0 +1,133 @@
//! 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, once parts lists are placed on sheets.
//!
//! 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`]); 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::sync::Arc;
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec3;
use solidcraft_geom::drawing_view::is_paper_coordinate;
use solidcraft_geom::paper::MAX_SIDE_MM;
use super::dimension::{Lookup, ModelEdge, Status, check_edge, edge_candidates};
use super::{Drawing, add_by_id, in_id_order, require};
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],
}
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 Drawing {
/// Adds `balloons`, each where its id puts it, as [`Drawing::add_dimensions`] adds dimensions.
pub fn add_balloons(&mut self, balloons: impl IntoIterator<Item = Arc<Balloon>>) {
add_by_id(&mut self.balloons, balloons, |b| b.id);
}
/// Balloon `id`, found by halves in the balloons' id order.
pub fn balloon(&self, id: u64) -> Option<&Balloon> {
self.balloon_index(id).and_then(|i| self.balloons.get(i)).map(|b| &**b)
}
/// Where balloon `id` is in [`Drawing::balloons`].
pub fn balloon_index(&self, id: u64) -> Option<usize> {
self.balloons.binary_search_by_key(&id, |b| b.id).ok()
}
/// The balloons' rules (the module's notes). The views are checked first, so they are in id
/// order here.
pub(super) fn check_balloons(&self) -> Result<()> {
require(self.balloons.len() <= MAX_BALLOONS, || format!("a drawing has at most {MAX_BALLOONS} balloons"))?;
require(in_id_order(&self.balloons, |b| b.id), || "a drawing lists its balloons by id".into())?;
for balloon in &self.balloons {
self.check_balloon(balloon).map_err(|e| DocError::Invalid(format!("balloon {}: {e}", ids::encode(balloon.id))))?;
}
Ok(())
}
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)))?;
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"))
}
}
/// 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.balloon(*id).ok_or_else(|| DocError::Unknown(format!("balloon {}", ids::encode(*id))))?;
Ok(match lookup.edges(&b.edge) {
// A split edge has no one middle: the pieces are listed, and none is chosen.
Ok(found) if !found.whole => {
Resolved { status: Status::Ambiguous(edge_candidates(&b.edge.body, &found)), point: None, exact: false }
}
Ok(found) => {
let point = found.pieces.first().and_then(|(_, line)| crate::joints::edge::middle(line)).map(|(p, _)| p);
let status = if point.is_some() { Status::Ok } else { Status::Lost(format!("edge `{}` has no length", b.edge.name)) };
Resolved { status, point, exact: found.exact }
}
Err(status) => Resolved { status, point: None, exact: false },
})
})
.collect()
}
}
#[cfg(test)]
#[path = "balloon_tests.rs"]
mod tests;
+194
View File
@@ -0,0 +1,194 @@
use std::collections::BTreeSet;
use serde_json::{Value, json};
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
use solidcraft_geom::drawing_view::NamedView;
use super::*;
use crate::drawing::Source;
use crate::drawing::view::{View, ViewKind, ViewPlace};
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.add_balloons([Arc::new(Balloon { id: *id, view, edge: edge("F1:side|F2:head", path), at })]);
}
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.balloon(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());
}
File diff suppressed because it is too large Load Diff
+515
View File
@@ -0,0 +1,515 @@
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
use solidcraft_geom::drawing_view::NamedView;
use super::*;
use crate::drawing::Source;
use crate::drawing::view::{View, ViewKind, ViewPlace};
use crate::drawing_view::{ViewDirection, ViewOverrides};
use crate::format::DRAWING;
use crate::ids::IdSource;
const SEED: u64 = 4896;
/// The body every reference here is on, and the feature that made it.
const BODY: &str = "Box";
const FEATURE: u64 = 7;
fn corner(edges: &[&str]) -> ModelPoint {
ModelPoint { body: BODY.into(), origin_feature: FEATURE, edges: edges.iter().map(|e| e.to_string()).collect(), centre: false, path: Vec::new() }
}
fn edge(name: &str) -> ModelEdge {
ModelEdge { body: BODY.into(), origin_feature: FEATURE, name: name.into(), path: Vec::new() }
}
/// A drawing of one design on one A3 sheet, its front view at 1:2 at (150, 150), and on the view a
/// linear dimension between two corners, an edge's length and an angle between two edges: their
/// ids, in the order they were added.
fn measured() -> (Drawing, [u64; 3]) {
let mut id_source = IdSource::seeded(SEED);
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
let mut d = Drawing::new("Box", "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) };
let settings = ViewOverrides { scale: Some("1:2".parse().unwrap()), ..ViewOverrides::default() };
d.add_views([Arc::new(View { id: view, sheet, kind, place: ViewPlace::At([150.0, 150.0]), settings, reference_shown_percent: 0.0 })]);
let kinds = [
DimensionKind::Linear {
a: corner(&["F1:front|F1:bottom", "F1:front|F1:left"]),
b: corner(&["F1:front|F1:bottom", "F1:front|F1:right"]),
direction: LinearDirection::Horizontal,
reading: Reading::Projected,
},
DimensionKind::Length { edge: edge("F1:front|F1:top"), direction: LinearDirection::Aligned, reading: Reading::True },
DimensionKind::Angle { a: edge("F1:front|F1:top"), b: edge("F2:chamfer|F1:front"), reading: Reading::Projected, other_side: true },
];
let texts = [TextPlace::Centred, TextPlace::At([0.0, -20.0]), TextPlace::At([12.5, 30.0])];
let mut added = [0; 3];
for ((kind, text), id) in kinds.into_iter().zip(texts).zip(&mut added) {
*id = d.allocate_id(&mut id_source).unwrap();
d.add_dimensions([Arc::new(Dimension { id: *id, view, kind, offset_mm: -10.0, text, decimals: 2, override_text: None, last_good: None })]);
}
d.validate().unwrap();
(d, added)
}
fn file(d: &Drawing) -> Value {
serde_json::from_str(&d.to_json().unwrap()).unwrap()
}
/// Dimension `id` in a drawing's file.
fn dim<'a>(v: &'a mut Value, id: &str) -> &'a mut Value {
v["dimensions"].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 dimension saves what it measures by name, its offset, its text's place and its decimals, and
/// nothing it leaves out. The dimensions save by id, whatever order they were added in, 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 dimensions_save_what_they_measure_and_read_back_as_saved() {
let (d, added) = measured();
let [linear_id, length_id, angle_id] = added.map(ids::encode);
let view = ids::encode(d.views[0].id);
let mut v = file(&d);
let box_corner = |edges: [&str; 2]| json!({"body": "Box", "origin_feature": 7, "edges": edges});
assert_eq!(
*dim(&mut v, &linear_id),
json!({
"id": linear_id, "view": view,
"kind": {"linear": {
"a": box_corner(["F1:front|F1:bottom", "F1:front|F1:left"]),
"b": box_corner(["F1:front|F1:bottom", "F1:front|F1:right"]),
"direction": "horizontal", "reading": "projected"
}},
"offset_mm": -10.0, "text": "centred", "decimals": 2
})
);
let length = dim(&mut v, &length_id).clone();
let top_edge = json!({"body": "Box", "origin_feature": 7, "name": "F1:front|F1:top"});
assert_eq!(length["kind"], json!({"length": {"edge": top_edge, "direction": "aligned", "reading": "true"}}));
assert_eq!(length["text"], json!({"at": [0.0, -20.0]}));
assert_eq!(dim(&mut v, &angle_id)["kind"]["angle"]["other_side"], json!(true));
let mut one_side = v.clone();
dim(&mut one_side, &angle_id)["kind"]["angle"].as_object_mut().unwrap().remove("other_side");
let one_side = Drawing::from_json(&one_side.to_string()).unwrap();
assert_eq!(dim(&mut file(&one_side), &angle_id)["kind"]["angle"].get("other_side"), None, "the usual side, not written");
let listed: Vec<u64> = v["dimensions"].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["dimensions"].as_array_mut().unwrap().reverse();
assert_eq!(Drawing::from_json(&other_order.to_string()).unwrap(), d);
let mut unsorted = d.clone();
unsorted.dimensions.reverse();
assert_eq!(unsorted.validate().unwrap_err().to_string(), "a drawing lists its dimensions by id");
let mut none = d.clone();
none.dimensions.clear();
assert_eq!(file(&none).get("dimensions"), None, "no dimensions, 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().dimensions.is_empty());
}
/// What reading refuses, each rule by name, on both sides of every cap: the view a dimension is
/// on, the names, the feature and the path of its references, a corner's edges, its direction and
/// reading, its offset and text place, its decimals and its text, its id, how many there are, and
/// fields it doesn't know.
#[test]
fn the_dimensions_rules_are_kept_on_reading() {
let (d, added) = measured();
let [linear_id, length_id, angle_id] = added.map(ids::encode);
let rule = |id: &str, rule: &str| format!("damaged drawing: dimension {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| dim(v, &linear_id)["view"] = json!(nothing)), rule(&linear_id, &on));
// Names: of the body, an edge and a corner's edges, 1 to 512 bytes without control characters.
let names = "a name in a reference is 1 to 512 bytes, without control characters";
let body = |name: &str| refused(&d, |v| dim(v, &length_id)["kind"]["length"]["edge"]["body"] = json!(name));
let edge_name = |name: &str| refused(&d, |v| dim(v, &angle_id)["kind"]["angle"]["b"]["name"] = json!(name));
let corner_edge = |name: &str| refused(&d, |v| dim(v, &linear_id)["kind"]["linear"]["a"]["edges"][1] = json!(name));
for fits in ["x".repeat(512), "é".repeat(256), "F1:front|F1:top".into()] {
assert_eq!([body(&fits), edge_name(&fits), corner_edge(&fits)], ["read", "read", "read"], "{} bytes", fits.len());
}
for broken in ["x".repeat(513), "é".repeat(256) + "a", "x".repeat(1_000_000), String::new(), "two\nlines".into(), "nul\0".into()] {
let expected = [rule(&length_id, names), rule(&angle_id, names), rule(&linear_id, names)];
assert_eq!([body(&broken), edge_name(&broken), corner_edge(&broken)], expected, "{} bytes", broken.len());
}
// A corner is where 2 to 16 named edges meet.
let edges = |n: usize| {
refused(&d, |v| dim(v, &linear_id)["kind"]["linear"]["b"]["edges"] = json!((0..n).map(|i| format!("F1:e{i}")).collect::<Vec<_>>()))
};
let meet = rule(&linear_id, "a corner is where 2 to 16 named edges meet");
assert_eq!([edges(0), edges(1), edges(2), edges(16), edges(17)], [meet.clone(), meet.clone(), "read".into(), "read".into(), meet]);
// A centre is of one named circle or arc, and saves as `centre`.
let centred = |n: usize| {
let mut v = file(&d);
let b = &mut dim(&mut v, &linear_id)["kind"]["linear"]["b"];
b["centre"] = json!(true);
b["edges"] = json!((0..n).map(|i| format!("F3:e{i}")).collect::<Vec<_>>());
let read = Drawing::from_json(&v.to_string()).map_err(|e| e.to_string())?;
Ok::<_, String>(dim(&mut file(&read), &linear_id)["kind"]["linear"]["b"].clone())
};
let one = Err(rule(&linear_id, "a centre is of one named circle or arc"));
assert_eq!([centred(0), centred(2)], [one.clone(), one]);
assert_eq!(centred(1), Ok(json!({"body": "Box", "origin_feature": 7, "edges": ["F3:e0"], "centre": true})));
// The feature that made the body, and the occurrence path.
let feature = rule(&length_id, "a reference names the feature that made its body");
assert_eq!(refused(&d, |v| dim(v, &length_id)["kind"]["length"]["edge"]["origin_feature"] = json!(0)), feature);
let path = |p: Vec<u64>| refused(&d, |v| dim(v, &angle_id)["kind"]["angle"]["a"]["path"] = json!(p));
let deep = rule(&angle_id, "an occurrence path is at most 64 occurrences, none of them 0");
assert_eq!([path(vec![3; 64]), path(vec![3; 65]), path(vec![3, 0]), path(vec![])], ["read".into(), deep.clone(), deep, "read".to_string()]);
// A direction to measure along that isn't zero, and a true length measured aligned.
let along = |a: Value| refused(&d, |v| dim(v, &length_id)["kind"]["length"]["direction"] = json!({"along": a}));
let zero = rule(&length_id, "the direction to measure along is zero or not finite");
assert_eq!(along(json!([0.0, 0.0])), zero);
assert!(along(json!([1e308, 1e308])).ends_with("is out of range"), "the file's own number check, before the record's");
// A whole number passes the file's check but would save as one past it: the record refuses it.
assert_eq!(along(json!([u64::MAX, 0])), rule(&length_id, "the direction to measure along is at most 1000000000 each way"));
assert_eq!(along(json!([1_000_000_000, 1])), rule(&length_id, "a true length is measured aligned"), "1e9 each way passes");
assert_eq!(along(json!([0.0, 2.0])), rule(&length_id, "a true length is measured aligned"), "along, read true");
let projected_along = refused(&d, |v| {
dim(v, &length_id)["kind"]["length"]["direction"] = json!({"along": [0.0, 2.0]});
dim(v, &length_id)["kind"]["length"]["reading"] = json!("projected");
});
assert_eq!(projected_along, "read");
let true_horizontal = refused(&d, |v| dim(v, &linear_id)["kind"]["linear"]["reading"] = json!("true"));
assert_eq!(true_horizontal, rule(&linear_id, "a true length is measured aligned"));
// On paper: the offset and the text's place.
let paper = rule(&angle_id, "its offset and its text's place are at most 5000 mm on paper");
let offset = |mm: f64| refused(&d, |v| dim(v, &angle_id)["offset_mm"] = json!(mm));
let text = |at: [f64; 2]| refused(&d, |v| dim(v, &angle_id)["text"] = json!({"at": at}));
assert_eq!([offset(5000.0), offset(-5000.0), offset(5000.001), offset(-5000.001)], ["read".into(), "read".into(), paper.clone(), paper.clone()]);
assert_eq!([text([5000.0, -5000.0]), text([5000.001, 0.0]), text([0.0, -5000.001])], ["read".into(), paper.clone(), paper]);
// Decimals, and the text shown instead of the number.
let decimals = |n: u64| refused(&d, |v| dim(v, &angle_id)["decimals"] = json!(n));
assert_eq!([decimals(0), decimals(6), decimals(7)], ["read".into(), "read".into(), rule(&angle_id, "it shows at most 6 decimals")]);
assert!(decimals(256).starts_with("damaged drawing: "), "past a u8");
let instead = |text: &str| refused(&d, |v| dim(v, &linear_id)["override_text"] = json!(text));
let text_rule = rule(&linear_id, "the text shown instead of its number is 1 to 1024 bytes");
assert_eq!(
[instead("≈80"), instead(&"x".repeat(1024)), instead(""), instead(&"x".repeat(1025))],
["read".into(), "read".into(), text_rule.clone(), text_rule]
);
// Its id is the drawing's: never another object's.
let view_id = ids::encode(d.views[0].id);
assert_eq!(refused(&d, |v| dim(v, &length_id)["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); 5] = [
|v, id| dim(v, id)["label"] = json!("A"),
|v, id| dim(v, id)["kind"]["linear"]["tolerance"] = json!(0.1),
|v, id| dim(v, id)["kind"]["linear"]["a"]["point"] = json!([0.0, 0.0, 0.0]),
|v, id| dim(v, id)["text"] = json!({"at": [1.0, 2.0], "angle": 3.0}),
|v, id| dim(v, id)["kind"] = json!({"baseline": {}}),
];
for (i, edit) in unknown.into_iter().enumerate() {
assert!(refused(&d, |v| edit(v, &linear_id)).starts_with("damaged drawing: "), "{i}");
}
// At most MAX_DIMENSIONS dimensions.
let many = |n: usize| {
let mut v = file(&d);
let one = dim(&mut v, &linear_id).clone();
let copies = (1..=n as u64).map(|i| {
let mut copy = one.clone();
copy["id"] = json!(ids::encode(i));
copy
});
v["dimensions"] = Value::Array(copies.collect());
Drawing::from_json(&v.to_string()).map(|d| d.dimensions.len()).map_err(|e| e.to_string())
};
assert_eq!(many(MAX_DIMENSIONS), Ok(MAX_DIMENSIONS));
assert_eq!(many(MAX_DIMENSIONS + 1), Err(format!("damaged drawing: a drawing has at most {MAX_DIMENSIONS} dimensions")));
}
/// Two lines at 45° read 45°, and 135° on the other side, whichever way each runs: the angle
/// between their directions is 45° or 135° by how the kernel runs the edges.
#[test]
fn an_angle_between_lines_reads_the_same_whichever_way_they_run() {
let (acute, obtuse) = (45f64.to_radians(), 135f64.to_radians());
for between in [acute, obtuse] {
assert!((between_lines(between, false) - acute).abs() < 1e-12, "{between}");
assert!((between_lines(between, true) - obtuse).abs() < 1e-12, "{between}");
}
}
/// A radial dimension saves its edge, and `diameter` only when it is one; its edge keeps an edge's
/// rules, and it has no reading.
#[test]
fn a_radial_dimension_saves_its_edge_and_whether_it_is_a_diameter() {
let (mut d, _) = measured();
let (view, id) = (d.views[0].id, d.allocate_id(&mut IdSource::seeded(SEED + 1)).unwrap());
let radial = |diameter| DimensionKind::Radial { edge: edge("F3:hole|F1:front"), diameter };
let kind = radial(true);
d.add_dimensions([Arc::new(Dimension {
id,
view,
kind,
offset_mm: 5.0,
text: TextPlace::Centred,
decimals: 1,
override_text: None,
last_good: None,
})]);
d.validate().unwrap();
let radial_id = ids::encode(id);
let mut v = file(&d);
let hole = json!({"body": "Box", "origin_feature": 7, "name": "F3:hole|F1:front"});
assert_eq!(dim(&mut v, &radial_id)["kind"], json!({"radial": {"edge": hole, "diameter": true}}));
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d);
dim(&mut v, &radial_id)["kind"]["radial"].as_object_mut().unwrap().remove("diameter");
let radius = Drawing::from_json(&v.to_string()).unwrap();
assert_eq!(radius.dimension(id).map(|r| &r.kind), Some(&radial(false)));
assert_eq!(dim(&mut file(&radius), &radial_id)["kind"]["radial"].get("diameter"), None, "a radius, not written");
let names = format!("damaged drawing: dimension {radial_id}: a name in a reference is 1 to 512 bytes, without control characters");
assert_eq!(refused(&d, |v| dim(v, &radial_id)["kind"]["radial"]["edge"]["name"] = json!("")), names);
assert!(refused(&d, |v| dim(v, &radial_id)["kind"]["radial"]["reading"] = json!("true")).starts_with("damaged drawing: "));
}
/// A chain saves its points in order and its direction, and keeps its rules on reading: 3 to
/// [`MAX_CHAIN_POINTS`] points, each with a point's rules, a direction to measure along that isn't
/// zero, no reading (it reads projected) and no text instead of its members' numbers.
#[test]
fn a_chain_saves_its_points_in_order_and_keeps_its_rules() {
let (mut d, _) = measured();
let (view, id) = (d.views[0].id, d.allocate_id(&mut IdSource::seeded(SEED + 1)).unwrap());
let hole = |n: u64| {
let rim = format!("F{n}:hole|F1:front");
ModelPoint { centre: true, ..corner(&[rim.as_str()]) }
};
let points = vec![corner(&["F1:front|F1:bottom", "F1:front|F1:left"]), hole(3), hole(4), hole(5)];
let kind = DimensionKind::Chain { points, direction: LinearDirection::Horizontal };
d.add_dimensions([Arc::new(Dimension {
id,
view,
kind,
offset_mm: 8.0,
text: TextPlace::Centred,
decimals: 1,
override_text: None,
last_good: None,
})]);
d.validate().unwrap();
let chain_id = ids::encode(id);
let mut v = file(&d);
let first = json!({"body": "Box", "origin_feature": 7, "edges": ["F1:front|F1:bottom", "F1:front|F1:left"]});
let centre = |n: u64| json!({"body": "Box", "origin_feature": 7, "edges": [format!("F{n}:hole|F1:front")], "centre": true});
let saved = json!({"chain": {"points": [first, centre(3), centre(4), centre(5)], "direction": "horizontal"}});
assert_eq!(dim(&mut v, &chain_id)["kind"], saved);
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d);
let rule = |rule: &str| format!("damaged drawing: dimension {chain_id}: {rule}");
let count = |n: usize| {
refused(&d, |v| {
let points = &mut dim(v, &chain_id)["kind"]["chain"]["points"];
let one = points[1].clone();
*points = json!(vec![one; n]);
})
};
let three_to = rule(&format!("a chain has 3 to {MAX_CHAIN_POINTS} points"));
let counts = [count(0), count(2), count(3), count(MAX_CHAIN_POINTS), count(MAX_CHAIN_POINTS + 1)];
assert_eq!(counts, [three_to.clone(), three_to.clone(), "read".into(), "read".into(), three_to]);
let two_edges = refused(&d, |v| dim(v, &chain_id)["kind"]["chain"]["points"][2]["edges"] = json!(["F3:a", "F3:b"]));
assert_eq!(two_edges, rule("a centre is of one named circle or arc"));
let zero = refused(&d, |v| dim(v, &chain_id)["kind"]["chain"]["direction"] = json!({"along": [0.0, 0.0]}));
assert_eq!(zero, rule("the direction to measure along is zero or not finite"));
let instead = refused(&d, |v| dim(v, &chain_id)["override_text"] = json!("60"));
assert_eq!(instead, rule("a chain shows its members' numbers, never a text instead"));
let reading = refused(&d, |v| dim(v, &chain_id)["kind"]["chain"]["reading"] = json!("projected"));
assert!(reading.starts_with("damaged drawing: "), "{reading}");
}
/// A cylinder dimension saves its face by name, and the face keeps a reference's rules.
#[test]
fn a_cylinder_dimension_saves_its_face() {
let (mut d, _) = measured();
let (view, id) = (d.views[0].id, d.allocate_id(&mut IdSource::seeded(SEED + 1)).unwrap());
let face = ModelFace { body: BODY.into(), origin_feature: FEATURE, name: "F4:boss".into(), path: vec![3] };
let kind = DimensionKind::Cylinder { face };
d.add_dimensions([Arc::new(Dimension {
id,
view,
kind,
offset_mm: 6.0,
text: TextPlace::Centred,
decimals: 1,
override_text: None,
last_good: None,
})]);
d.validate().unwrap();
let cylinder = ids::encode(id);
let mut v = file(&d);
let saved = json!({"cylinder": {"face": {"body": "Box", "origin_feature": 7, "name": "F4:boss", "path": [3]}}});
assert_eq!(dim(&mut v, &cylinder)["kind"], saved);
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d);
let rule = |rule: &str| format!("damaged drawing: dimension {cylinder}: {rule}");
let changed = |key: &str, value: Value| refused(&d, |v| dim(v, &cylinder)["kind"]["cylinder"]["face"][key] = value);
assert_eq!(changed("name", json!("")), rule("a name in a reference is 1 to 512 bytes, without control characters"));
assert_eq!(changed("origin_feature", json!(0)), rule("a reference names the feature that made its body"));
assert_eq!(changed("path", json!([0])), rule("an occurrence path is at most 64 occurrences, none of them 0"));
assert!(changed("edges", json!(["F4:boss"])).starts_with("damaged drawing: "), "a face has no edges");
}
/// A dimension between two cylinders' silhouettes saves each one's face and side, and its
/// direction; each face keeps a reference's rules, a side is near or far, and it reads as the view
/// shows it (no `reading`).
#[test]
fn a_silhouettes_dimension_saves_each_face_and_side() {
let (mut d, _) = measured();
let (view, id) = (d.views[0].id, d.allocate_id(&mut IdSource::seeded(SEED + 1)).unwrap());
let face = |name: &str| ModelFace { body: BODY.into(), origin_feature: FEATURE, name: name.into(), path: Vec::new() };
let (a, b) = (Silhouette { face: face("F4:boss"), side: SilhouetteSide::Near }, Silhouette { face: face("F5:pin"), side: SilhouetteSide::Far });
let kind = DimensionKind::Silhouettes { a, b, direction: LinearDirection::Vertical };
d.add_dimensions([Arc::new(Dimension {
id,
view,
kind,
offset_mm: 6.0,
text: TextPlace::Centred,
decimals: 1,
override_text: None,
last_good: None,
})]);
d.validate().unwrap();
let silhouettes = ids::encode(id);
let mut v = file(&d);
let face = |name: &str| json!({"body": "Box", "origin_feature": 7, "name": name});
let saved = json!({"silhouettes": {"a": {"face": face("F4:boss"), "side": "near"}, "b": {"face": face("F5:pin"), "side": "far"}, "direction": "vertical"}});
assert_eq!(dim(&mut v, &silhouettes)["kind"], saved);
assert_eq!(Drawing::from_json(&v.to_string()).unwrap(), d);
let rule = |rule: &str| format!("damaged drawing: dimension {silhouettes}: {rule}");
let changed = |edit: &dyn Fn(&mut Value)| refused(&d, |v| edit(&mut dim(v, &silhouettes)["kind"]["silhouettes"]));
assert_eq!(changed(&|k| k["b"]["face"]["name"] = json!("")), rule("a name in a reference is 1 to 512 bytes, without control characters"));
assert_eq!(changed(&|k| k["a"]["face"]["origin_feature"] = json!(0)), rule("a reference names the feature that made its body"));
assert_eq!(changed(&|k| k["direction"] = json!({"along": [0.0, 0.0]})), rule("the direction to measure along is zero or not finite"));
let malformed: [fn(&mut Value); 3] = [|k| k["a"]["side"] = json!("left"), |k| k["reading"] = json!("true"), |k| k["b"]["edges"] = json!([])];
for edit in malformed {
let why = changed(&edit);
assert!(why.starts_with("damaged drawing: "), "{why}");
}
}
/// A direction along a picked line saves as its plane coordinates and reads back as the same
/// direction: what W19a measures along.
#[test]
fn a_direction_along_a_line_saves_as_its_coordinates() {
let along = LinearDirection::Along(Vec2::new(0.6, 0.8));
assert_eq!(serde_json::to_value(along).unwrap(), json!({"along": [0.6, 0.8]}));
assert_eq!(serde_json::from_value::<LinearDirection>(json!({"along": [0.6, 0.8]})).unwrap(), along);
assert_eq!(serde_json::to_value(Reading::True).unwrap(), json!("true"));
}
/// A last good value saves only when there is one, reads back as saved, and an older drawing reads
/// with none.
#[test]
fn a_last_good_value_saves_only_when_there_is_one() {
let (mut d, [linear, ..]) = measured();
let id = ids::encode(linear);
assert_eq!(dim(&mut file(&d), &id).get("last_good"), None, "none: not written");
let at = d.dimension_index(linear).unwrap();
Arc::make_mut(&mut d.dimensions[at]).last_good = Some(80.5);
let mut v = file(&d);
assert_eq!(dim(&mut v, &id)["last_good"], json!(80.5));
let read = Drawing::from_json(&v.to_string()).unwrap();
assert_eq!(read, d);
assert_eq!(read.to_json().unwrap(), d.to_json().unwrap(), "the same bytes again");
dim(&mut v, &id).as_object_mut().unwrap().remove("last_good");
assert_eq!(Drawing::from_json(&v.to_string()).unwrap().dimension(linear).unwrap().last_good, None);
}
/// A last good value is from 0 to the largest number a drawing saves; a file with any other is
/// refused, and so is a record built with one that JSON can't hold.
#[test]
fn a_last_good_value_keeps_its_rule() {
let (d, [linear, ..]) = measured();
let id = ids::encode(linear);
let with = |value: Value| refused(&d, |v| dim(v, &id)["last_good"] = value);
for good in [json!(0.0), json!(-0.0), json!(5e-324), json!(80), json!(MAX_REAL)] {
assert_eq!(with(good.clone()), "read", "{good}");
}
for bad in [json!(-5e-324), json!(-1.0)] {
assert!(with(bad.clone()).contains("last good"), "{bad}: {}", with(bad.clone()));
}
// Past every drawing's largest number: the file's own check refuses it first.
let past = MAX_REAL * (1.0 + f64::EPSILON);
for bad in [json!(past), json!(f64::MAX), json!(-f64::MAX)] {
assert_ne!(with(bad.clone()), "read", "{bad}");
}
let mut direct = d.clone();
Arc::make_mut(&mut direct.dimensions[0]).last_good = Some(past);
assert!(direct.validate().is_err());
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut direct = d.clone();
Arc::make_mut(&mut direct.dimensions[0]).last_good = Some(bad);
assert!(direct.validate().is_err(), "{bad}");
}
}
/// What dimensions last read goes into their records by drawing and dimension, leaving out a
/// dimension or a drawing no longer there and a value the record would refuse. A drawing with no
/// new value stays shared with the document it was copied from, and that document keeps none.
#[test]
fn keep_last_good_keeps_only_what_a_record_may_save() {
let (first, [a, b, c]) = measured();
let second = Drawing { id: first.id + 1, ..first.clone() };
let (one, two) = (first.id, second.id);
let mut doc = Document::new("Box");
doc.drawings = vec![Arc::new(first), Arc::new(second)];
let before = doc.clone();
let values = BTreeMap::from([
((one, a), 80.0),
((one, b), MAX_REAL),
((one, c), MAX_REAL * 2.0),
((one, 1), 5.0),
((one, u64::MAX), 5.0),
((two + 1, a), 5.0),
]);
doc.keep_last_good(&values);
let read = |doc: &Document, drawing: usize, id| doc.drawings[drawing].dimension(id).unwrap().last_good;
assert_eq!([read(&doc, 0, a), read(&doc, 0, b), read(&doc, 0, c)], [Some(80.0), Some(MAX_REAL), None]);
assert_eq!([read(&doc, 1, a), read(&doc, 1, b), read(&doc, 1, c)], [None; 3], "another drawing's dimensions, with the same ids");
assert!(Arc::ptr_eq(&doc.drawings[1], &before.drawings[1]), "no new value: still shared");
assert_eq!(read(&before, 0, a), None, "the document it was copied from keeps none");
doc.drawings.iter().for_each(|d| d.validate().unwrap());
let kept = doc.clone();
doc.keep_last_good(&values);
assert!(Arc::ptr_eq(&doc.drawings[0], &kept.drawings[0]), "the same values again: still shared");
}
/// Whatever a read hands over, at every boundary and over random bit patterns, the saved record is
/// one a file can open: a design is never lost to its last good values.
#[test]
fn keep_last_good_never_writes_a_record_a_file_refuses() {
let (d, [linear, ..]) = measured();
let id = d.id;
let mut doc = Document::new("Box");
doc.drawings = vec![Arc::new(d)];
let tricky = [0.0, -0.0, 5e-324, MAX_REAL, MAX_REAL * (1.0 + f64::EPSILON), -1.0, 1e12, f64::MAX, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
let mut bits = 0x9E37_79B9_7F4A_7C15_u64;
let random = std::iter::repeat_with(|| {
bits = bits.wrapping_mul(0x5851_F42D_4C95_7F2D).wrapping_add(1);
f64::from_bits(bits)
});
for value in tricky.into_iter().chain(random.take(2000)) {
doc.keep_last_good(&BTreeMap::from([((id, linear), value)]));
let saved = doc.drawings[0].to_json().unwrap();
let read = Drawing::from_json(&saved).unwrap_or_else(|e| panic!("{value}: {e}"));
assert_eq!(read.dimension(linear).unwrap().last_good, doc.drawings[0].dimension(linear).unwrap().last_good, "{value}");
}
}
@@ -1,7 +1,7 @@
# Pending drawing-format changes
One file per change to what a drawing file saves that no release has shipped yet. Drawings
(`"format": "solidcraft-drawing/N"`, #3890) are versioned by the same code as designs
One file per change to what a drawing saves (in its design's file) that no release has shipped
yet. Drawings (`"format": "solidcraft-drawing/N"`, #3890) are versioned by the same code as designs
(`crates/doc/src/format.rs`, `Family`), from their own first format, 1. Adding a file here is the
whole format bump: never edit the `DRAWING` family in `format.rs`, another change's file or a
`../v<N>/` folder.
@@ -0,0 +1,5 @@
//! Drawings save their balloons (`balloons`, #3874 W26): each on one of the drawing's views, pointing
//! at a placed part by a named edge of it (the edge's body, the feature that made it, its name and
//! the occurrences the body is placed through), with its place on paper from the view's centre. An
//! older drawing has no balloons. The last release refuses a drawing saved with them, as a newer
//! format.
@@ -0,0 +1,4 @@
//! A drawing's dimensions save their last good value (`last_good`, W19d, #4896) when they have one:
//! what the dimension last read, so one that can't be measured any more still says what it was
//! after the design is reopened. An older drawing has none, and each dimension has one again from
//! its next read. The last release refuses a drawing saved with them, as a newer format.
@@ -0,0 +1,7 @@
//! Drawings save their dimensions (`dimensions`, #4896 W19b): each on one of the drawing's views,
//! measuring named corners, circles' and arcs' centres, edges or faces of the view's model (a
//! linear distance, an edge's length, an angle, a circle's or an arc's radius or diameter, a chain
//! of distances along one line, a cylinder's diameter between its silhouettes, or the distance
//! between two cylinders' silhouettes), with how far its line sits from them and where its text
//! sits on paper, its decimals and any text shown instead of its number. An older drawing has no
//! dimensions. The last release refuses a drawing saved with them, as a newer format.
@@ -0,0 +1,3 @@
//! Drawings save the font their text is set in (`font`, #4890): a font family's name, never a
//! file. An older drawing has none, and is set in the built-in font. The last release refuses a
//! drawing saved with one, as a newer format.
@@ -0,0 +1,5 @@
//! A drawing lives in the file of the design it draws (#3890): it has an `id`, unique among its
//! design's drawings, and at most one source, the design it is in, which has an `id` and no `path`.
//! No step: no release saved a drawing before this, and a drawing file of its own is no longer
//! read, so an older drawing is refused as damaged. The last release refuses a design that holds a
//! drawing (the design's newer format number).
@@ -0,0 +1,4 @@
//! Drawings save their revisions (`revisions`, #3874): one revision record for the whole drawing,
//! its rows (revision, description, designer, checker, date, hidden) in the order they were added,
//! each with its id. An older drawing has no revisions. The last release refuses a drawing saved
//! with them, as a newer format.
@@ -0,0 +1,5 @@
//! Drawings save their views (`views`, #4816): base views of their designs and the views projected
//! from them, on their sheets, each with the settings it sets itself; and the projection every
//! projected view follows (`projection`: `first` or `third` angle). An older drawing has no views,
//! and its projection is third angle. The last release refuses a drawing saved with them, as a
//! newer format.
+291
View File
@@ -0,0 +1,291 @@
//! A drawing's views (W17a, #4816): each a view of one of the drawing's designs, on one of its
//! sheets. A **base view** draws a source from a direction ([`ViewDirection`]). A **projected
//! view** sits on one side of its parent and looks from that side, or from the side away from it
//! in first angle ([`Drawing::projection`], [`projected_basis`]); lined up with its parent, it saves
//! only its place along the free axis, as the other comes from the parent ([`Drawing::view_centre`]).
//!
//! What a view sets itself is in [`ViewOverrides`]: each setting it leaves out follows its parent,
//! then the default, and [`Drawing::resolve_view`] is that one rule for every reader. A projected
//! view's source and basis come from its chain of parents, so neither is saved.
//!
//! The rules, beside the record's: a view is on one of the drawing's sheets; a base view draws one
//! of its sources, has a scale and looks from a direction that makes a view; every parent is a view
//! of the drawing, no chain of parents loops, and a chain is at most [`MAX_VIEW_DEPTH`] views; a
//! view lined up with its parent is on the parent's sheet; a view's place is on paper
//! ([`is_paper_coordinate`]); at most [`MAX_VIEWS`] views, listed by id ([`Drawing::add_views`]).
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use solidcraft_geom::drawing_view::{ProjectedSide, Scale, is_paper_coordinate, projected_basis};
use solidcraft_geom::paper::MAX_SIDE_MM;
use solidcraft_geom::{Plane, Vec2};
use solidcraft_render::VisualStyle;
use super::{Drawing, add_by_id, in_id_order, require};
use crate::drawing_view::{MAX_REFERENCE_SHOWN_PERCENT, TangentEdges, ViewDirection, ViewOverrides, ViewSettings};
use crate::{DocError, Result, ids};
/// The most views a drawing may have.
pub const MAX_VIEWS: usize = 4096;
/// The most views in a chain, from a base view down through the views projected from it.
pub const MAX_VIEW_DEPTH: usize = 32;
/// One view of a drawing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct View {
#[serde(with = "crate::ids")]
pub id: u64,
/// The sheet it is on.
#[serde(with = "crate::ids")]
pub sheet: u64,
pub kind: ViewKind,
/// Where its centre sits on its sheet.
pub place: ViewPlace,
/// What it sets itself; each setting left out follows its parent, then the default.
#[serde(default, skip_serializing_if = "ViewOverrides::is_empty")]
pub settings: ViewOverrides,
/// How far its boundary grows from the model's box towards the box that also holds its reference
/// parts, each side on its own: 0 to [`MAX_REFERENCE_SHOWN_PERCENT`], each view its own.
#[serde(default, skip_serializing_if = "is_zero")]
pub reference_shown_percent: f64,
}
/// What a view draws, and from where.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum ViewKind {
/// The design of `source`, seen from `view`.
Base {
#[serde(with = "crate::ids")]
source: u64,
view: ViewDirection,
},
/// Projected from `parent`, on `side` of it.
Projected {
#[serde(with = "crate::ids")]
parent: u64,
side: ProjectedSide,
},
}
/// Where a view's centre sits on its sheet, in paper millimetres.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum ViewPlace {
/// At this point of the sheet.
At([f64; 2]),
/// Lined up with its parent, this far along the free axis: across for a view to the left or
/// right of it, up for one above or below. A projected view only.
Aligned(f64),
}
/// Who gave a resolved setting its value.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SetBy {
/// The view itself.
This,
/// The parent of this id, the nearest that sets it.
Parent(u64),
/// No view of the chain: the default.
Default,
}
/// A setting's value and who gave it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Setting<T> {
pub value: T,
pub set_by: SetBy,
}
/// A view as it draws: the source it draws, which way it looks, and each setting with who gave it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedView {
pub source: u64,
pub basis: Plane,
pub scale: Setting<Scale>,
pub display_style: Setting<VisualStyle>,
pub reference_style: Setting<VisualStyle>,
pub reference_hides_model: Setting<bool>,
pub tangent_edges: Setting<TangentEdges>,
pub reference_shown_percent: f64,
}
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
/// The views from `id` up to its base view, nearest first, each found by `find`: refused when a
/// parent isn't one, the chain loops, or it is longer than [`MAX_VIEW_DEPTH`].
fn chain<'a>(id: u64, find: impl Fn(u64) -> Option<&'a View>) -> Result<Vec<&'a View>> {
let mut chain: Vec<&View> = Vec::new();
let mut at = id;
loop {
let view = find(at).ok_or_else(|| match chain.as_slice() {
[] => DocError::Invalid(format!("the drawing has no view {}", ids::encode(at))),
[_] => DocError::Invalid(format!("its parent {} isn't one of the drawing's views", ids::encode(at))),
// Further up, the view whose parent is missing is named: it is that view's parent.
[.., child] => DocError::Invalid(format!(
"its chain of parents reaches view {}, whose parent {} isn't one of the drawing's views",
ids::encode(child.id),
ids::encode(at)
)),
})?;
require(chain.iter().all(|v| v.id != at), || "its chain of parents loops".into())?;
chain.push(view);
match view.kind {
ViewKind::Base { .. } => return Ok(chain),
ViewKind::Projected { parent, .. } => at = parent,
}
require(chain.len() < MAX_VIEW_DEPTH, || format!("a chain of views is at most {MAX_VIEW_DEPTH} views"))?;
}
}
/// The nearest view of `chain` (the view first) that sets a setting `field` reads, with its value.
fn inherited<T: Copy>(chain: &[&View], field: impl Fn(&ViewOverrides) -> Option<T>) -> Option<Setting<T>> {
chain.iter().enumerate().find_map(|(i, view)| {
let set_by = if i == 0 { SetBy::This } else { SetBy::Parent(view.id) };
field(&view.settings).map(|value| Setting { value, set_by })
})
}
/// [`inherited`], or the default `value` when no view of the chain sets it.
fn or_default<T: Copy>(chain: &[&View], field: impl Fn(&ViewOverrides) -> Option<T>, value: T) -> Setting<T> {
inherited(chain, field).unwrap_or(Setting { value, set_by: SetBy::Default })
}
impl Drawing {
/// Adds `views`, each where its id puts it: a drawing lists its views by id, never in the order
/// they were added in, so the same views always save as the same bytes. Every edit keeps the
/// order ([`Drawing::validate`]), and reading puts a file's views in it.
pub fn add_views(&mut self, views: impl IntoIterator<Item = Arc<View>>) {
add_by_id(&mut self.views, views, |v| v.id);
}
/// View `id`, found by halves in the views' id order.
pub fn view(&self, id: u64) -> Option<&View> {
self.view_index(id).and_then(|i| self.views.get(i)).map(|v| &**v)
}
/// `views` and every view projected from them, directly or through others. A parent can sort
/// after its child, so each pass over the views finds at least one more level of a chain, which
/// is at most [`MAX_VIEW_DEPTH`] views.
pub fn with_projected(&self, views: &BTreeSet<u64>) -> BTreeSet<u64> {
let mut all = views.clone();
for _ in 0..MAX_VIEW_DEPTH {
let before = all.len();
for view in &self.views {
if matches!(view.kind, ViewKind::Projected { parent, .. } if all.contains(&parent)) {
all.insert(view.id);
}
}
if all.len() == before {
break;
}
}
all
}
/// Where view `id` is in [`Drawing::views`].
pub fn view_index(&self, id: u64) -> Option<usize> {
self.views.binary_search_by_key(&id, |v| v.id).ok()
}
/// The views' rules (the module's notes).
pub(super) fn check_views(&self) -> Result<()> {
require(self.views.len() <= MAX_VIEWS, || format!("a drawing has at most {MAX_VIEWS} views"))?;
// In id order, so each view after this is found by halves.
require(in_id_order(&self.views, |v| v.id), || "a drawing lists its views by id".into())?;
for view in &self.views {
self.check_view(view).map_err(|e| DocError::Invalid(format!("view {}: {e}", ids::encode(view.id))))?;
}
Ok(())
}
fn check_view(&self, view: &View) -> Result<()> {
let on = |sheet: u64| format!("it is on sheet {}, which the drawing doesn't have", ids::encode(sheet));
require(self.sheets.iter().any(|s| s.id == view.sheet), || on(view.sheet))?;
let percent = view.reference_shown_percent;
require(percent.is_finite() && (0.0..=MAX_REFERENCE_SHOWN_PERCENT).contains(&percent), || {
format!("its reference_shown_percent is 0 to {MAX_REFERENCE_SHOWN_PERCENT}")
})?;
let place = match view.place {
ViewPlace::At([x, y]) => is_paper_coordinate(x) && is_paper_coordinate(y),
ViewPlace::Aligned(along) => is_paper_coordinate(along),
};
require(place, || format!("its place is at most {MAX_SIDE_MM} mm from the sheet's origin"))?;
match view.kind {
ViewKind::Base { source, view: direction } => {
let drawn = || format!("it draws source {}, which the drawing doesn't have", ids::encode(source));
require(self.sources.iter().any(|s| s.id == source), drawn)?;
require(view.settings.scale.is_some(), || "a base view has a scale".into())?;
require(matches!(view.place, ViewPlace::At(_)), || "a base view is placed at a point: it has no parent to line up with".into())?;
direction.basis().map(|_| ()).map_err(|e| DocError::Invalid(e.to_string()))
}
ViewKind::Projected { parent, .. } => {
self.chain_of(view.id)?;
let parent_sheet = self.view(parent).map(|p| p.sheet);
let lined_up = matches!(view.place, ViewPlace::Aligned(_));
require(!lined_up || parent_sheet == Some(view.sheet), || "a view lined up with its parent is on its parent's sheet".into())
}
}
}
/// View `id` as it draws: its base view's source, its basis turned down its chain of parents
/// ([`projected_basis`], in the drawing's projection), and each setting from the nearest view
/// of the chain that sets it, then the default. One rule for every reader of a view.
pub fn resolve_view(&self, id: u64) -> Result<ResolvedView> {
let chain = self.chain_of(id)?;
let Some(ViewKind::Base { source, view: direction }) = chain.last().map(|base| base.kind) else {
return Err(DocError::Invalid(format!("view {} has no base view", ids::encode(id))));
};
let mut basis = direction.basis().map_err(|e| DocError::Invalid(e.to_string()))?;
for view in chain.iter().rev() {
if let ViewKind::Projected { side, .. } = view.kind {
basis = projected_basis(&basis, side, self.projection).map_err(|e| DocError::Invalid(e.to_string()))?;
}
}
let scale = inherited(&chain, |s| s.scale).ok_or_else(|| DocError::Invalid(format!("view {} has no scale", ids::encode(id))))?;
let defaults = ViewSettings::default();
Ok(ResolvedView {
source,
basis,
scale,
display_style: or_default(&chain, |s| s.display_style, defaults.display_style),
reference_style: or_default(&chain, |s| s.reference_style, defaults.reference_style),
reference_hides_model: or_default(&chain, |s| s.reference_hides_model, defaults.reference_hides_model),
tangent_edges: or_default(&chain, |s| s.tangent_edges, defaults.tangent_edges),
reference_shown_percent: chain.first().map_or(0.0, |view| view.reference_shown_percent),
})
}
/// Where view `id`'s centre sits on its sheet, in paper millimetres. A view placed at a point
/// is there. One lined up with its parent takes its own place along the free axis and its
/// parent's centre across it ([`ProjectedSide::on_free_axis`]): both draw the same model, so
/// their extents share that axis's middle, and they line up whatever their scales.
pub fn view_centre(&self, id: u64) -> Result<Vec2> {
let mut centre = Vec2::ZERO;
// From the base view down to this one, each lined up with the one before or placed apart.
for view in self.chain_of(id)?.iter().rev() {
centre = match (view.place, view.kind) {
(ViewPlace::At([x, y]), _) => Vec2::new(x, y),
(ViewPlace::Aligned(along), ViewKind::Projected { side, .. }) => side.on_free_axis(centre, along),
(ViewPlace::Aligned(_), ViewKind::Base { .. }) => {
return Err(DocError::Invalid(format!("base view {} isn't placed at a point", ids::encode(view.id))));
}
};
}
Ok(centre)
}
/// The views from `id` up to its base view, nearest first ([`chain`]).
fn chain_of(&self, id: u64) -> Result<Vec<&View>> {
chain(id, |id| self.view(id))
}
}
#[cfg(test)]
#[path = "view_tests.rs"]
mod tests;
+328
View File
@@ -0,0 +1,328 @@
use std::collections::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_geom::cell::Projection;
use solidcraft_geom::drawing_sheet::{self, Orientation, PaperSize};
use solidcraft_geom::drawing_view::NamedView;
use super::*;
use crate::drawing::{Excluded, Sheet, Source};
use crate::format::DRAWING;
use crate::ids::IdSource;
const SEED: u64 = 4816;
/// A drawing of one design on one A3 sheet, its front view at 1:2 centred at (150, 150), and the
/// id source that made it.
fn front() -> (Drawing, IdSource, u64) {
let mut id_source = IdSource::seeded(SEED);
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
let mut d = Drawing::new("Plate", "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 front = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
let id = add(&mut d, &mut id_source, front, ViewPlace::At([150.0, 150.0]), scaled("1:2"));
(d, id_source, id)
}
/// A view on the drawing's first sheet, showing no more for its reference parts.
fn add(d: &mut Drawing, id_source: &mut IdSource, kind: ViewKind, place: ViewPlace, settings: ViewOverrides) -> u64 {
let id = d.allocate_id(id_source).unwrap();
let sheet = d.sheets[0].id;
d.add_views([Arc::new(View { id, sheet, kind, place, settings, reference_shown_percent: 0.0 })]);
id
}
/// View `id` of `d`, to change.
fn view_mut(d: &mut Drawing, id: u64) -> &mut View {
d.views.iter_mut().find(|v| v.id == id).map(Arc::make_mut).unwrap()
}
fn projected(parent: u64, side: ProjectedSide) -> ViewKind {
ViewKind::Projected { parent, side }
}
fn scaled(scale: &str) -> ViewOverrides {
ViewOverrides { scale: Some(scale.parse().unwrap()), ..ViewOverrides::default() }
}
fn styled(style: VisualStyle) -> ViewOverrides {
ViewOverrides { display_style: Some(style), ..ViewOverrides::default() }
}
/// The front view, its right view lined up with it and drawing hidden lines too, and the right
/// view's top view placed apart.
fn three() -> (Drawing, IdSource, [u64; 3]) {
let (mut d, mut id_source, front) = front();
let hidden = styled(VisualStyle::VisibleHidden);
let right = add(&mut d, &mut id_source, projected(front, ProjectedSide::Right), ViewPlace::Aligned(320.0), hidden);
let top = add(&mut d, &mut id_source, projected(right, ProjectedSide::Above), ViewPlace::At([320.0, 250.0]), ViewOverrides::default());
d.validate().unwrap();
(d, id_source, [front, right, top])
}
fn file(d: &Drawing) -> Value {
serde_json::from_str(&d.to_json().unwrap()).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 view saves its kind, its place and what it sets itself, and nothing it leaves out; the views
/// save by id, and the drawing saves its projection. It reads back as saved, the same bytes again,
/// and an older drawing reads with no views, in third angle.
#[test]
fn views_save_what_they_set_and_read_back_as_saved() {
let (d, _, [front, right, top]) = three();
let v = file(&d);
let [front_id, right_id, top_id, sheet, source] = [front, right, top, d.sheets[0].id, d.sources[0].id].map(ids::encode);
assert_eq!(v["projection"], json!("third"));
assert_eq!(
v["views"],
json!([
{"id": front_id, "sheet": sheet, "kind": {"base": {"source": source, "view": "front"}}, "place": {"at": [150.0, 150.0]}, "settings": {"scale": "1:2"}},
{"id": top_id, "sheet": sheet, "kind": {"projected": {"parent": right_id, "side": "above"}}, "place": {"at": [320.0, 250.0]}},
{"id": right_id, "sheet": sheet, "kind": {"projected": {"parent": front_id, "side": "right"}}, "place": {"aligned": 320.0}, "settings": {"display_style": "visible_hidden"}},
])
);
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 first = v.clone();
first["projection"] = json!("first");
assert_eq!(Drawing::from_json(&first.to_string()).unwrap().projection, Projection::First);
let older = json!({
"format": DRAWING.format_string(1), "id": "0000000000002", "name": "Old", "sources": [], "language": "en",
"sheets": [{"id": "0000000000001", "name": "S", "sheet": {"size": {"standard": "a4"}}}]
});
let old = Drawing::from_json(&older.to_string()).unwrap();
assert!(old.views.is_empty() && old.projection == Projection::Third);
assert_eq!(file(&old).get("views"), None, "no views, none written");
}
/// A view takes the views projected from it, through others and whichever way their ids sort, and
/// nothing above it or beside it; none given, none taken.
#[test]
fn a_views_projected_views_are_found_down_its_chains() {
let (mut d, mut id_source, [front, right, top]) = three();
let left = add(&mut d, &mut id_source, projected(front, ProjectedSide::Left), ViewPlace::Aligned(20.0), ViewOverrides::default());
let set = |ids: &[u64]| ids.iter().copied().collect::<BTreeSet<u64>>();
assert_eq!(d.with_projected(&set(&[front])), set(&[front, right, top, left]));
assert_eq!(d.with_projected(&set(&[right])), set(&[right, top]));
assert_eq!(d.with_projected(&set(&[top, left])), set(&[top, left]));
assert_eq!(d.with_projected(&set(&[])), set(&[]));
assert_eq!(d.with_projected(&set(&[99])), set(&[99]));
}
/// A drawing lists its views by id, whatever order they were added in: the same views added the
/// other way round make the same drawing and save as the same bytes. A file that lists them in
/// another order (saved before views went by id, or edited by hand) reads by id and saves as those
/// bytes. An edit that leaves them in another order breaks the record's rule.
#[test]
fn views_are_listed_by_id_whatever_order_they_were_added_in() {
let (d, _, added) = three();
let mut by_id = added.to_vec();
by_id.sort_unstable();
assert_ne!(by_id, added, "the seed's ids aren't in the order the views were added in");
let listed = |d: &Drawing| d.views.iter().map(|v| v.id).collect::<Vec<_>>();
let saved: Vec<u64> = file(&d)["views"].as_array().unwrap().iter().map(|v| ids::decode(v["id"].as_str().unwrap()).unwrap()).collect();
assert_eq!((listed(&d), saved), (by_id.clone(), by_id.clone()));
let mut reversed = d.clone();
reversed.views.clear();
reversed.add_views(d.views.iter().rev().cloned());
assert_eq!(reversed, d);
let bytes = d.to_json().unwrap();
assert_eq!(reversed.to_json().unwrap(), bytes);
let mut other_order = file(&d);
other_order["views"].as_array_mut().unwrap().reverse();
let reopened = Drawing::from_json(&other_order.to_string()).unwrap();
assert_eq!(reopened, d);
assert_eq!((listed(&reopened), reopened.to_json().unwrap()), (by_id, bytes));
let mut unsorted = d.clone();
unsorted.views.reverse();
assert_eq!(unsorted.validate().unwrap_err().to_string(), "a drawing lists its views by id");
}
/// What reading refuses, each rule by name: where a view is and what it draws, its parents, its
/// place and margin, its settings, its id, and how many views there are.
#[test]
fn the_views_rules_are_kept_on_reading() {
let (mut d, mut id_source, [front, right, top]) = three();
let [front_id, right_id, top_id] = [front, right, top].map(ids::encode);
let nothing = ids::encode(99);
let rule = |id: &str, rule: &str| format!("damaged drawing: view {id}: {rule}");
// The file lists the views by id: front ([0]), top ([1]), then right ([2]).
// Where it is and what it draws.
let on = format!("it is on sheet {nothing}, which the drawing doesn't have");
assert_eq!(refused(&d, |v| v["views"][2]["sheet"] = json!(nothing)), rule(&right_id, &on));
let draws = format!("it draws source {nothing}, which the drawing doesn't have");
assert_eq!(refused(&d, |v| v["views"][0]["kind"]["base"]["source"] = json!(nothing)), rule(&front_id, &draws));
let no_scale = refused(&d, |v| {
v["views"][0].as_object_mut().unwrap().remove("settings");
});
assert_eq!(no_scale, rule(&front_id, "a base view has a scale"));
let lined_up = refused(&d, |v| v["views"][0]["place"] = json!({"aligned": 10.0}));
assert_eq!(lined_up, rule(&front_id, "a base view is placed at a point: it has no parent to line up with"));
let looking = |direction: Value| refused(&d, |v| v["views"][0]["kind"]["base"]["view"] = json!({"direction": direction, "up": [0.0, 0.0, 1.0]}));
assert_eq!(looking(json!([0.0, 1.0, 0.0])), "read");
assert_eq!(looking(json!([0.0, 0.0, 0.0])), rule(&front_id, "drawing view: the view direction is zero or not finite"));
assert_eq!(looking(json!([0.0, 0.0, -1.0])), rule(&front_id, "drawing view: `up` lies along the view direction: give another up"));
// Its parents: one that isn't a view, itself, and loops of two and three views.
let parent = |view: usize, parent: &str| {
let parent = parent.to_string();
move |v: &mut Value| v["views"][view]["kind"] = json!({"projected": {"parent": parent, "side": "left"}})
};
let missing = format!("its parent {nothing} isn't one of the drawing's views");
assert_eq!(refused(&d, parent(1, &nothing)), rule(&top_id, &missing));
// Further up the chain, the view whose parent is missing is named. The top view is read
// before the right view, so its chain finds the right view's missing parent.
let further = format!("its chain of parents reaches view {right_id}, whose parent {nothing} isn't one of the drawing's views");
assert_eq!(refused(&d, parent(2, &nothing)), rule(&top_id, &further));
assert_eq!(refused(&d, parent(1, &front_id)), "read");
assert_eq!(refused(&d, parent(1, &top_id)), rule(&top_id, "its chain of parents loops"));
assert_eq!(refused(&d, parent(0, &right_id)), rule(&front_id, "its chain of parents loops"), "two views");
assert_eq!(refused(&d, parent(0, &top_id)), rule(&front_id, "its chain of parents loops"), "three views");
// A view lined up with its parent is on its parent's sheet; one placed apart may be on any.
let other = d.allocate_id(&mut id_source).unwrap();
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
d.sheets.push(Arc::new(Sheet {
id: other,
name: "Other".into(),
sheet: a4,
border: None,
dpi: 300.0,
documents: None,
excluded: Excluded::default(),
}));
let other_id = ids::encode(other);
assert_eq!(
refused(&d, |v| v["views"][2]["sheet"] = json!(other_id)),
rule(&right_id, "a view lined up with its parent is on its parent's sheet")
);
assert_eq!(refused(&d, |v| v["views"][1]["sheet"] = json!(other_id)), "read");
// Its place and its reference_shown_percent.
let off_paper = "its place is at most 5000 mm from the sheet's origin";
assert_eq!(refused(&d, |v| v["views"][1]["place"] = json!({"at": [5000.0, -5000.0]})), "read");
assert_eq!(refused(&d, |v| v["views"][1]["place"] = json!({"at": [5000.001, 0.0]})), rule(&top_id, off_paper));
assert_eq!(refused(&d, |v| v["views"][2]["place"] = json!({"aligned": -5000.001})), rule(&right_id, off_paper));
for (percent, read) in [(0.0, true), (100.0, true), (-0.001, false), (100.001, false)] {
let why = refused(&d, |v| v["views"][1]["reference_shown_percent"] = json!(percent));
let expected = if read { "read".to_string() } else { rule(&top_id, "its reference_shown_percent is 0 to 100") };
assert_eq!(why, expected, "{percent}");
}
// Its settings and its fields, only those it knows; a resolution is the sheet's, not a view's.
let unknown: [fn(&mut Value); 5] = [
|v| v["views"][0]["label"] = json!("A"),
|v| v["views"][0]["settings"]["dpi"] = json!(300.0),
|v| v["views"][0]["kind"]["base"]["scale"] = json!("1:1"),
|v| v["views"][1]["kind"]["projected"]["aligned"] = json!(true),
|v| v["views"][0]["place"] = json!({"at": [1.0, 2.0], "aligned": 3.0}),
];
for (i, edit) in unknown.into_iter().enumerate() {
assert!(refused(&d, edit).starts_with("damaged drawing: "), "{i}");
}
assert!(refused(&d, |v| v["views"][0]["settings"]["scale"] = json!("1:0")).starts_with("damaged drawing: "));
// Its id is the drawing's: never another object's.
let sheet_id = ids::encode(d.sheets[0].id);
assert_eq!(refused(&d, |v| v["views"][2]["id"] = json!(front_id)), format!("damaged drawing: two objects have the id {front_id}"));
assert_eq!(refused(&d, |v| v["views"][2]["id"] = json!(sheet_id)), format!("damaged drawing: two objects have the id {sheet_id}"));
// At most MAX_VIEWS views.
let many = |n: usize| {
let mut v = file(&d);
let one = v["views"][0].clone();
v["views"] = Value::Array(
(1..=n as u64)
.map(|i| {
let mut view = one.clone();
view["id"] = json!(ids::encode(i));
view
})
.collect(),
);
Drawing::from_json(&v.to_string()).map(|d| d.views.len()).map_err(|e| e.to_string())
};
assert_eq!(many(MAX_VIEWS), Ok(MAX_VIEWS));
assert_eq!(many(MAX_VIEWS + 1), Err(format!("damaged drawing: a drawing has at most {MAX_VIEWS} views")));
}
/// A chain of views is at most MAX_VIEW_DEPTH views, the base view one of them.
#[test]
fn a_chain_of_views_is_at_most_its_depth() {
let (mut d, mut id_source, front) = front();
let mut last = front;
for _ in 1..MAX_VIEW_DEPTH {
last = add(&mut d, &mut id_source, projected(last, ProjectedSide::Right), ViewPlace::At([10.0, 10.0]), ViewOverrides::default());
}
assert_eq!(d.views.len(), MAX_VIEW_DEPTH);
d.validate().unwrap();
let deepest = add(&mut d, &mut id_source, projected(last, ProjectedSide::Right), ViewPlace::At([10.0, 10.0]), ViewOverrides::default());
let why = d.validate().unwrap_err().to_string();
assert_eq!(why, format!("view {}: a chain of views is at most {MAX_VIEW_DEPTH} views", ids::encode(deepest)));
}
/// A view resolves through its chain of parents: its basis turned down the chain in the drawing's
/// projection, each setting from the nearest view that sets it, then the default, saying who gave
/// it, and its own reference_shown_percent.
#[test]
fn a_view_resolves_through_its_chain() {
let (mut d, _, [front, right, top]) = three();
let view = view_mut(&mut d, top);
view.settings.tangent_edges = Some(TangentEdges::Show);
view.reference_shown_percent = 2.0;
let resolved = d.resolve_view(top).unwrap();
assert_eq!(resolved.source, d.sources[0].id);
let right_basis = projected_basis(&NamedView::Front.basis(), ProjectedSide::Right, Projection::Third).unwrap();
assert_eq!(resolved.basis, projected_basis(&right_basis, ProjectedSide::Above, Projection::Third).unwrap());
assert_eq!(resolved.scale, Setting { value: "1:2".parse().unwrap(), set_by: SetBy::Parent(front) });
assert_eq!(resolved.display_style, Setting { value: VisualStyle::VisibleHidden, set_by: SetBy::Parent(right) });
assert_eq!(resolved.tangent_edges, Setting { value: TangentEdges::Show, set_by: SetBy::This });
assert_eq!(resolved.reference_style, Setting { value: VisualStyle::Visible, set_by: SetBy::Default });
assert_eq!(resolved.reference_hides_model, Setting { value: false, set_by: SetBy::Default });
assert_eq!(resolved.reference_shown_percent, 2.0);
// The base view gives its own; its projected views follow the drawing's projection.
let base = d.resolve_view(front).unwrap();
assert_eq!((base.basis, base.scale.set_by, base.display_style.set_by), (NamedView::Front.basis(), SetBy::This, SetBy::Default));
assert_eq!(d.resolve_view(right).unwrap().basis, NamedView::Right.basis());
d.projection = Projection::First;
assert_eq!(d.resolve_view(right).unwrap().basis, NamedView::Left.basis());
// A view's own setting wins over its parent's.
view_mut(&mut d, top).settings.display_style = Some(VisualStyle::Visible);
assert_eq!(d.resolve_view(top).unwrap().display_style, Setting { value: VisualStyle::Visible, set_by: SetBy::This });
// Four quarter turns the same way bring the front view back.
let (mut d, mut id_source, front) = self::front();
let mut last = front;
for _ in 0..4 {
last = add(&mut d, &mut id_source, projected(last, ProjectedSide::Right), ViewPlace::At([10.0, 10.0]), ViewOverrides::default());
}
let turned = d.resolve_view(last).unwrap().basis;
let close = |a: Vec3, b: Vec3| (a - b).len() < 1e-12;
assert!(close(turned.x, Vec3::X) && close(turned.y, Vec3::Z), "{turned:?}");
assert_eq!(d.resolve_view(99).unwrap_err().to_string(), format!("the drawing has no view {}", ids::encode(99)));
}
/// A view placed at a point is there; one lined up with its parent takes its own place along the
/// free axis and its parent's centre across it, down a chain, and follows when the parent moves;
/// one placed apart stays. A base view lined up with nothing has no place, nor its views.
#[test]
fn a_views_centre_is_its_point_or_lined_up_with_its_parent() {
let (mut d, mut id_source, [front, right, top]) = three();
let back = add(&mut d, &mut id_source, projected(right, ProjectedSide::Right), ViewPlace::Aligned(450.0), ViewOverrides::default());
let below = add(&mut d, &mut id_source, projected(right, ProjectedSide::Below), ViewPlace::Aligned(60.0), ViewOverrides::default());
d.validate().unwrap();
let centres = |d: &Drawing| [front, right, top, back, below].map(|id| d.view_centre(id).unwrap());
let at = |x, y| Vec2::new(x, y);
assert_eq!(centres(&d), [at(150.0, 150.0), at(320.0, 150.0), at(320.0, 250.0), at(450.0, 150.0), at(320.0, 60.0)]);
view_mut(&mut d, front).place = ViewPlace::At([140.0, 130.0]);
assert_eq!(centres(&d), [at(140.0, 130.0), at(320.0, 130.0), at(320.0, 250.0), at(450.0, 130.0), at(320.0, 60.0)]);
view_mut(&mut d, front).place = ViewPlace::Aligned(10.0);
for id in [front, right, top, back, below] {
assert_eq!(d.view_centre(id).unwrap_err().to_string(), format!("base view {} isn't placed at a point", ids::encode(front)));
}
assert_eq!(d.view_centre(99).unwrap_err().to_string(), format!("the drawing has no view {}", ids::encode(99)));
}
+289 -58
View File
@@ -1,5 +1,14 @@
use serde_json::json;
use solidcraft_geom::drawing_measure::{LinearDirection, Reading};
use solidcraft_geom::drawing_sheet::{Margins, Orientation, PaperSize, SheetSize};
use solidcraft_geom::drawing_view::{NamedView, ProjectedSide};
use solidcraft_render::VisualStyle;
use crate::drawing_view::{TangentEdges, ViewDirection, ViewOverrides};
use balloon::Balloon;
use dimension::{DimensionKind, ModelEdge, ModelPoint, TextPlace};
use view::{ViewKind, ViewPlace};
use super::*;
@@ -11,18 +20,20 @@ fn own_border() -> DrawingBorderProfile {
DrawingBorderProfile { filing_margin: true, trimming_mark_mm: Some([10.0, 5.0]), ..DrawingBorderProfile::iso() }
}
/// Three designs on two sheets: the first sheet (A3, the ISO border) documents component 7 of the
/// first design; the second (A4 portrait, a border of its own, 600 dpi, left out of printing)
/// documents the second design. The third design is on no sheet yet.
/// The design it is in, on two sheets: the first (A3, the ISO border) documents its component 7;
/// the second (A4 portrait, a border of its own, 600 dpi, left out of printing) the design itself.
/// On the first sheet, the front view at 1:2, its right view lined up with it and drawing hidden
/// lines too, and the right view's top view placed apart; on the second, an iso view at 1:1,
/// tangent edges shown, with reference_shown_percent 5. On the front view, a dimension of each kind:
/// between two corners, a placed copy's edge length along a direction, and an angle with its text
/// given; and a balloon on the placed copy's top edge.
fn plate() -> Drawing {
let mut id_source = IdSource::seeded(SEED);
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
let mut d = Drawing::new("Plate assembly", "en-GB", "Assembly", a3, Some(DrawingBorderProfile::iso()), &mut id_source).unwrap();
for path in ["plate.sc", "../parts/bolt.sc", "bracket.sc"] {
let id = d.allocate_id(&mut id_source).unwrap();
d.sources.push(Source { id, path: path.into() });
}
let (plate_id, bolt_id) = (d.sources[0].id, d.sources[1].id);
let mut d = Drawing::new("Plate assembly", "en-GB", "Assembly", a3, Some(DrawingBorderProfile::iso()), &BTreeSet::new(), &mut id_source).unwrap();
let id = d.allocate_id(&mut id_source).unwrap();
d.sources.push(Source { id });
let plate_id = d.sources[0].id;
let id = d.allocate_id(&mut id_source).unwrap();
d.sheets.push(Arc::new(Sheet {
id,
@@ -30,12 +41,54 @@ fn plate() -> Drawing {
sheet: drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Portrait),
border: Some(own_border()),
dpi: 600.0,
documents: Some(SheetComponent { source: bolt_id, component: 0 }),
documents: Some(SheetComponent { source: plate_id, component: 0 }),
excluded: Excluded { count: false, print: true },
}));
Arc::make_mut(&mut d.sheets[0]).documents = Some(SheetComponent { source: plate_id, component: 7 });
d.properties.set(crate::metadata::Owner::Design, "author", json!("Jane Doe")).unwrap();
Arc::make_mut(&mut d.properties).set(crate::metadata::Owner::Design, "author", json!("Jane Doe")).unwrap();
d.units = Units::Fixed { unit: "in".into() };
let (assembly, bolt_sheet) = (d.sheets[0].id, d.sheets[1].id);
let mut add = |d: &mut Drawing, sheet, kind, place, settings, reference_shown_percent| {
let id = d.allocate_id(&mut id_source).unwrap();
d.add_views([Arc::new(View { id, sheet, kind, place, settings, reference_shown_percent })]);
id
};
let half = ViewOverrides { scale: Some("1:2".parse().unwrap()), ..ViewOverrides::default() };
let front = ViewKind::Base { source: plate_id, view: ViewDirection::Named(NamedView::Front) };
let front = add(&mut d, assembly, front, ViewPlace::At([150.0, 150.0]), half, 0.0);
let hidden = ViewOverrides { display_style: Some(VisualStyle::VisibleHidden), ..ViewOverrides::default() };
let right = ViewKind::Projected { parent: front, side: ProjectedSide::Right };
let right = add(&mut d, assembly, right, ViewPlace::Aligned(320.0), hidden, 0.0);
let top = ViewKind::Projected { parent: right, side: ProjectedSide::Above };
add(&mut d, assembly, top, ViewPlace::At([320.0, 250.0]), ViewOverrides::default(), 0.0);
let iso = ViewKind::Base { source: plate_id, view: ViewDirection::Named(NamedView::IsoFrontRight) };
let full_size = ViewOverrides { scale: Some("1:1".parse().unwrap()), tangent_edges: Some(TangentEdges::Show), ..ViewOverrides::default() };
add(&mut d, bolt_sheet, iso, ViewPlace::At([105.0, 148.5]), full_size, 5.0);
let corner = |edges: [&str; 2]| ModelPoint {
body: "Plate".into(),
origin_feature: 1,
edges: edges.map(String::from).to_vec(),
centre: false,
path: Vec::new(),
};
let edge = |name: &str| ModelEdge { body: "Plate".into(), origin_feature: 1, name: name.into(), path: vec![3, 7] };
let measures = [
DimensionKind::Linear {
a: corner(["F1:front|F1:bottom", "F1:front|F1:left"]),
b: corner(["F1:front|F1:bottom", "F1:front|F1:right"]),
direction: LinearDirection::Horizontal,
reading: Reading::Projected,
},
DimensionKind::Length { edge: edge("F1:front|F1:top"), direction: LinearDirection::Along(Vec2::new(1.0, 1.0)), reading: Reading::Projected },
DimensionKind::Angle { a: edge("F1:front|F1:top"), b: edge("F2:chamfer|F1:front"), reading: Reading::True, other_side: true },
];
let texts = [(TextPlace::Centred, None), (TextPlace::At([0.0, -20.0]), None), (TextPlace::At([12.5, 30.0]), Some("≈45°".to_string()))];
for (kind, (text, override_text)) in measures.into_iter().zip(texts) {
let id = d.allocate_id(&mut id_source).unwrap();
d.add_dimensions([Arc::new(Dimension { id, view: front, kind, offset_mm: -10.0, text, decimals: 1, override_text, last_good: None })]);
}
let id = d.allocate_id(&mut id_source).unwrap();
d.add_balloons([Arc::new(Balloon { id, view: front, edge: edge("F1:front|F1:top"), at: [60.0, 45.0] })]);
d.validate().unwrap();
d
}
@@ -43,7 +96,7 @@ fn plate() -> Drawing {
/// One A4 sheet that documents nothing, no sources: the base the caps grow from.
fn bare() -> Drawing {
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
Drawing::new("Bare", "en", "Sheet", a4, None, &mut IdSource::seeded(SEED)).unwrap()
Drawing::new("Bare", "en", "Sheet", a4, None, &BTreeSet::new(), &mut IdSource::seeded(SEED)).unwrap()
}
fn file(d: &Drawing) -> Value {
@@ -78,17 +131,14 @@ fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() {
assert_eq!(reopened.to_json().unwrap(), saved);
assert_eq!(plate().to_json().unwrap(), saved, "a seeded run makes the same drawing");
let v = file(&d);
let [plate_id, bolt_id, bracket_id] = [0, 1, 2].map(|i| ids::encode(d.sources[i].id));
let plate_id = ids::encode(d.sources[0].id);
let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id));
assert_eq!(assembly_id, "1ge30wkvq38r0", "the seed's first id, as splitmix64 gives it");
assert_eq!(v["id"], "1ge30wkvq38r0", "the seed's first id, as splitmix64 gives it");
assert_eq!(v["format"], DRAWING.format_string(DRAWING.current()));
assert_eq!(v.get("format_pending").is_some(), !DRAWING.pending_names().is_empty());
assert_eq!(v.get("next_id"), None);
assert_eq!((&v["name"], &v["language"]), (&json!("Plate assembly"), &json!("en-GB")));
assert_eq!(
v["sources"],
json!([{"id": plate_id, "path": "plate.sc"}, {"id": bolt_id, "path": "../parts/bolt.sc"}, {"id": bracket_id, "path": "bracket.sc"}])
);
assert_eq!(v["sources"], json!([{"id": plate_id}]));
assert_eq!(v["properties"], json!({"author": "Jane Doe"}));
assert_eq!(v["units"], json!({"fixed": {"unit": "in"}}));
assert_eq!((&v["sheets"][0]["id"], &v["sheets"][0]["dpi"]), (&json!(assembly_id), &json!(300.0)));
@@ -100,7 +150,7 @@ fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() {
json!({
"id": bolt_sheet_id, "name": "Bolt", "sheet": {"size": {"standard": "a4"}, "orientation": "portrait"},
"border": serde_json::to_value(own_border()).unwrap(), "dpi": 600.0,
"documents": {"source": bolt_id, "component": 0}, "excluded": {"count": false, "print": true}
"documents": {"source": plate_id, "component": 0}, "excluded": {"count": false, "print": true}
})
);
}
@@ -111,6 +161,7 @@ fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() {
fn a_file_may_leave_out_what_has_a_default() {
let minimal = json!({
"format": DRAWING.format_string(1),
"id": "0000000000002",
"name": "Bracket",
"sources": [],
"language": "de",
@@ -125,7 +176,7 @@ fn a_file_may_leave_out_what_has_a_default() {
let mut spelt = minimal.clone();
spelt["format"] = json!("solidcraft-drawing/+1");
assert_eq!(read(&spelt).unwrap().format, DRAWING.format_string(DRAWING.current()));
for key in ["name", "sources", "language", "sheets"] {
for key in ["id", "name", "sources", "language", "sheets"] {
let mut v = minimal.clone();
v.as_object_mut().unwrap().remove(key);
let why = refusal(&v);
@@ -133,7 +184,7 @@ fn a_file_may_leave_out_what_has_a_default() {
}
}
/// Every cap on both sides: sheets, sources, the bytes of paths and names (two-byte characters too).
/// Every cap on both sides: sheets, sources, the bytes of names (two-byte characters too).
#[test]
fn every_cap_holds_on_both_sides() {
let base = file(&bare());
@@ -155,18 +206,15 @@ fn every_cap_holds_on_both_sides() {
for n in [0, 1001] {
assert_eq!(refusal(&with("sheets", sheets(n))), "damaged drawing: a drawing has 1 to 1000 sheets", "{n} sheets");
}
// Sources from id 1: the sheet's id is the seed's, far from them.
let sources = |n: u64| Value::Array((1..=n).map(|id| json!({"id": ids::encode(id), "path": format!("part{id}.sc")})).collect());
assert_eq!(read(&with("sources", sources(64))).unwrap().sources.len(), 64);
assert_eq!(refusal(&with("sources", sources(65))), "damaged drawing: a drawing draws at most 64 designs");
let path = |p: &str| refusal(&with("sources", json!([{"id": ids::encode(1), "path": p}])));
for fits in ["a".repeat(4096), "é".repeat(2048), "/".into()] {
assert_eq!(path(fits.as_str()), "read", "{} bytes", fits.len());
}
let rule = "damaged drawing: source 0000000000001: a design's path is 1 to 4096 bytes, without NUL";
for broken in ["a".repeat(4097), "é".repeat(2048) + "a", String::new(), "plate\0.sc".into()] {
assert_eq!(path(broken.as_str()), rule, "{} bytes", broken.len());
// Sources from id 1: the drawing's and the sheet's ids are the seed's, far from them. A drawing
// draws the design it is in, by an id alone: a path is a field this build doesn't know.
let sources = |n: u64| Value::Array((1..=n).map(|id| json!({"id": ids::encode(id)})).collect());
for n in [0, 1] {
assert_eq!(read(&with("sources", sources(n))).unwrap().sources.len(), n as usize);
}
assert_eq!(refusal(&with("sources", sources(2))), "damaged drawing: a drawing draws only the design it is in");
let with_path = refusal(&with("sources", json!([{"id": ids::encode(1), "path": "plate.solidcraft"}])));
assert!(with_path.starts_with("damaged drawing: unknown field `path`"), "{with_path}");
let names = |name: &str| {
let mut sheet_named = base.clone();
sheet_named["sheets"][0]["name"] = json!(name);
@@ -206,6 +254,23 @@ fn the_language_is_a_bcp_47_tag_of_2_to_35_characters() {
}
}
/// A drawing names its font by family: a file is refused when the drawing is read, as it is when a
/// command sets it.
#[test]
fn a_font_file_in_a_drawing_is_refused_when_read() {
let base = file(&bare());
let why = |font: &str| {
let mut v = base.clone();
v["font"] = json!(font);
refusal(&v)
};
assert_eq!(why("IBM Plex Mono"), "read");
for path in ["C:/Fonts/arial.ttf", "/usr/share/fonts/a", "Futura.otf"] {
let e = why(path);
assert!(e.contains("the drawing's font") && e.contains("drawings name fonts by family"), "{path}: {e}");
}
}
/// A fixed unit is a length unit of the expression unit table, written as the table writes it.
#[test]
fn a_fixed_unit_is_a_length_unit_of_the_expression_table() {
@@ -248,29 +313,49 @@ fn a_border_is_saved_as_a_copy_of_its_profile() {
assert_eq!(read(&edited).unwrap().sheets[0].border, None, "no drawing border");
}
/// A sheet's drawing goes inside its drawing border: 10 mm in from each edge with the ISO border,
/// 20 mm on the left with a filing margin, and the whole paper with no border.
#[test]
fn a_sheets_drawing_space_is_inside_its_border_or_the_whole_paper() {
let d = plate();
let space = |sheet: &Sheet| sheet.drawing_space().map(|b| (b.min, b.max)).unwrap();
assert_eq!(space(&d.sheets[0]), (Vec2::new(10.0, 10.0), Vec2::new(410.0, 287.0)), "A3 landscape, ISO");
assert_eq!(space(&d.sheets[1]), (Vec2::new(20.0, 10.0), Vec2::new(200.0, 287.0)), "A4 portrait, filing margin");
let bare = Sheet { border: None, ..(*d.sheets[0]).clone() };
assert_eq!(space(&bare), (Vec2::ZERO, Vec2::new(420.0, 297.0)), "no border");
let low = drawing_sheet::Sheet { size: SheetSize::Custom { sides_mm: [20.5, 297.0] }, ..d.sheets[0].sheet };
let narrow = Sheet { sheet: low, ..(*d.sheets[0]).clone() };
assert!(narrow.drawing_space().is_err(), "margins that leave no drawing space");
}
/// What reading refuses: each damage the rules name, refused rather than patched, and files this
/// build can't read.
#[test]
fn damaged_files_are_refused_and_never_patched() {
let d = plate();
let plate_id = ids::encode(d.sources[0].id);
let (drawing_id, plate_id) = (ids::encode(d.id), ids::encode(d.sources[0].id));
let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id));
// Two objects with one id, of one kind and of two kinds.
assert_eq!(refused(&d, |v| v["sources"][1]["id"] = json!(plate_id)), format!("damaged drawing: two objects have the id {plate_id}"));
assert_eq!(refused(&d, |v| v["sheets"][1]["id"] = json!(assembly_id)), format!("damaged drawing: two objects have the id {assembly_id}"));
assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(bolt_sheet_id)), format!("damaged drawing: two objects have the id {bolt_sheet_id}"));
// Two objects with one id, of one kind and of two kinds, the drawing itself among them.
let two = |id: &str| format!("damaged drawing: two objects have the id {id}");
assert_eq!(refused(&d, |v| v["sources"][0]["id"] = json!(drawing_id)), two(&drawing_id));
assert_eq!(refused(&d, |v| v["sheets"][1]["id"] = json!(assembly_id)), two(&assembly_id));
assert_eq!(refused(&d, |v| v["sources"][0]["id"] = json!(bolt_sheet_id)), two(&bolt_sheet_id));
assert_eq!(refused(&d, |v| v["id"] = json!(assembly_id)), two(&assembly_id));
// An id that isn't one, where an object has it and where a reference names it; no number form,
// and no counter.
let not_an_id = "damaged drawing: an id is 13 characters of lowercase Crockford base32, not all 0";
for text in ["0000000000000", "FZZZZZZZZZZZZ", "000000000001", "0000000000001 ", "000000000000o", "g000000000000"] {
assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(text)), not_an_id, "{text:?}");
assert_eq!(refused(&d, |v| v["id"] = json!(text)), not_an_id, "{text:?}");
assert_eq!(refused(&d, |v| v["sources"][0]["id"] = json!(text)), not_an_id, "{text:?}");
assert_eq!(refused(&d, |v| v["sheets"][1]["documents"]["source"] = json!(text)), not_an_id, "{text:?}");
}
assert!(refused(&d, |v| v["sources"][2]["id"] = json!(5)).starts_with("damaged drawing: invalid type"));
assert!(refused(&d, |v| v["sources"][0]["id"] = json!(5)).starts_with("damaged drawing: invalid type"));
assert!(refused(&d, |v| v["next_id"] = json!(6)).contains("unknown field `next_id`"));
let mut in_memory = d.clone();
in_memory.sources[2].id = 0;
assert_eq!(in_memory.validate().unwrap_err().to_string(), "an object has the id 0, which is never one");
for zero in [|d: &mut Drawing| d.id = 0, |d: &mut Drawing| d.sources[0].id = 0] {
let mut in_memory = d.clone();
zero(&mut in_memory);
assert_eq!(in_memory.validate().unwrap_err().to_string(), "an object has the id 0, which is never one");
}
// A reference to nothing, or to an object of another kind; a source a sheet documents removed.
for missing in [ids::encode(99), assembly_id.clone()] {
let expected = format!("damaged drawing: sheet {bolt_sheet_id}: it documents source {missing}, which the drawing doesn't have");
@@ -282,7 +367,15 @@ fn damaged_files_are_refused_and_never_patched() {
assert_eq!(without_plate, format!("damaged drawing: sheet {assembly_id}: it documents source {plate_id}, which the drawing doesn't have"));
// The paper, its border and its resolution, through the sheet's own rules.
let custom = |sides: Value| refused(&d, |v| v["sheets"][0]["sheet"]["size"] = json!({"custom": {"sides_mm": sides}}));
assert_eq!(custom(json!([5000.0, 0.5])), "read");
assert_eq!(custom(json!([5000.0, 100.0])), "read");
// A side the border's margins fill leaves no drawing space; without a border it is paper.
let no_space = format!("damaged drawing: sheet {assembly_id}: drawing sheet: the margins leave no drawing space");
assert_eq!(custom(json!([5000.0, 0.5])), no_space);
let bare = refused(&d, |v| {
v["sheets"][0]["sheet"]["size"] = json!({"custom": {"sides_mm": [5000.0, 0.5]}});
v["sheets"][0]["border"] = Value::Null;
});
assert_eq!(bare, "read");
for sides in [json!([5000.1, 100.0]), json!([0.0, 100.0]), json!([-1.0, 100.0])] {
let expected = format!("damaged drawing: sheet {assembly_id}: drawing sheet: a custom side is finite, above 0 and at most 5000 mm");
assert_eq!(custom(sides), expected);
@@ -312,7 +405,7 @@ fn damaged_files_are_refused_and_never_patched() {
assert!(in_memory.validate().unwrap_err().to_string().starts_with(&format!("sheet {bolt_sheet_id}: drawing sheet: a custom side")));
// An unknown field anywhere.
let unknown: [fn(&mut Value); 8] = [
|v| v["views"] = json!([]),
|v| v["annotations"] = json!([]),
|v| v["sources"][0]["name"] = json!("Plate"),
|v| v["sheets"][0]["scale"] = json!(1),
|v| v["sheets"][0]["sheet"]["colour"] = json!("white"),
@@ -330,7 +423,7 @@ fn damaged_files_are_refused_and_never_patched() {
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
let why = refused(&d, |v| {
v["format"] = json!(DRAWING.format_string(newer));
v["views"] = json!([]);
v["annotations"] = json!([]);
v["sheets"][0]["dpi"] = json!(1e10);
});
assert_eq!(
@@ -360,8 +453,8 @@ fn damaged_files_are_refused_and_never_patched() {
assert_eq!(Drawing::from_json("[]").unwrap_err().to_string(), "not a SolidCraft drawing (expected a JSON object)");
assert!(Drawing::from_json("").unwrap_err().to_string().starts_with("not a SolidCraft drawing: "));
let deep: Value = serde_json::from_str(&format!("{}1{}", "[".repeat(100), "]".repeat(100))).unwrap();
assert_eq!(refused(&d, |v| v["views"] = deep), "damaged drawing: nested too deeply");
let deeper = format!("{{\"format\": \"{drawing_format}\", \"views\": {}1{}}}", "[".repeat(200), "]".repeat(200));
assert_eq!(refused(&d, |v| v["annotations"] = deep), "damaged drawing: nested too deeply");
let deeper = format!("{{\"format\": \"{drawing_format}\", \"annotations\": {}1{}}}", "[".repeat(200), "]".repeat(200));
assert!(Drawing::from_json(&deeper).unwrap_err().to_string().starts_with("not a SolidCraft drawing: "));
let text = d.to_json().unwrap();
let at_cap = text.clone() + &" ".repeat(MAX_DRAWING_BYTES - text.len());
@@ -371,18 +464,48 @@ fn damaged_files_are_refused_and_never_patched() {
/// A run never gives an id twice: not one in use, and not a removed object's. Only another run from
/// the same seed, which tests and replays start from the same file, can give a removed id again.
/// What is on a sheet is its views and the dimensions in them, and taking the sheet out takes
/// them too. A view elsewhere projected from one of them is left without its parent, which the
/// rules refuse.
#[test]
fn a_sheet_goes_with_everything_on_it() {
let d = plate();
let (assembly, bolt) = (d.sheets[0].id, d.sheets[1].id);
let on = d.on_sheet(assembly);
assert_eq!((on.views.len(), on.dimensions.len(), on.balloons.len()), (3, 3, 1), "{on:?}");
assert_eq!(on.dimensions, d.dimensions.iter().map(|x| x.id).collect::<Vec<_>>());
let bolt_on = d.on_sheet(bolt);
assert_eq!((bolt_on.views.len(), bolt_on.dimensions.len(), bolt_on.balloons.len()), (1, 0, 0), "{bolt_on:?}");
assert!(d.on_sheet(d.id).is_empty() && !on.is_empty(), "not a sheet: nothing on it");
let mut without = d.clone();
assert_eq!(without.remove_sheet(bolt), bolt_on);
without.validate().unwrap();
assert_eq!((without.sheets.len(), without.views.len(), without.dimensions.len(), without.balloons.len()), (1, 3, 3, 1));
let mut without = d.clone();
assert_eq!(without.remove_sheet(assembly), on);
without.validate().unwrap();
assert_eq!((without.sheets.len(), without.views.len(), without.dimensions.len(), without.balloons.len()), (1, 1, 0, 0));
// The top view, placed apart, moved to the bolt's sheet: its parent is on the assembly's.
let mut moved = d.clone();
let top = moved.views.iter().position(|v| v.sheet == assembly && matches!((v.kind, v.place), (ViewKind::Projected { .. }, ViewPlace::At(_))));
Arc::make_mut(&mut moved.views[top.unwrap()]).sheet = bolt;
moved.validate().unwrap();
moved.remove_sheet(assembly);
assert!(moved.validate().unwrap_err().to_string().contains("parent"), "{:?}", moved.validate());
}
#[test]
fn a_run_never_gives_an_id_twice() {
let mut id_source = IdSource::seeded(SEED);
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
let mut d = Drawing::new("Ids", "en", "Sheet", a4, None, &mut id_source).unwrap();
let mut given = BTreeSet::from([d.sheets[0].id]);
for _ in 0..MAX_SOURCES {
let mut d = Drawing::new("Ids", "en", "Sheet", a4, None, &BTreeSet::new(), &mut id_source).unwrap();
let mut given = BTreeSet::from([d.id, d.sheets[0].id]);
for _ in 0..64 {
let id = d.allocate_id(&mut id_source).unwrap();
d.sources.push(Source { id, path: "part.sc".into() });
d.sheets.push(Arc::new(Sheet { id, ..(*d.sheets[0]).clone() }));
assert!(given.insert(id), "{id:#x} given twice");
}
let removed = d.sources.pop().unwrap().id;
let removed = d.sheets.pop().map_or(0, |s| s.id);
for _ in 0..10_000 {
let id = d.allocate_id(&mut id_source).unwrap();
assert!(id != removed && given.insert(id), "{id:#x} given twice");
@@ -420,7 +543,7 @@ fn a_drawing_too_large_to_reopen_is_not_saved() {
let sheet = r#"{"id":"0000000000001","name":"S","sheet":{"size":{"standard":"a4"}}}"#;
let format = DRAWING.format_string(1);
format!(
r#"{{"format":"{format}","name":"A","sources":[],"language":"en","sheets":[{sheet}],"properties":{{"com.example":{{"items":[{items}]}}}}}}"#
r#"{{"format":"{format}","id":"0000000000002","name":"A","sources":[],"language":"en","sheets":[{sheet}],"properties":{{"com.example":{{"items":[{items}]}}}}}}"#
)
};
// The case found in review (#3891): 2,000,000 zeros read in at about 4 MB and indent past 16 MiB.
@@ -430,19 +553,24 @@ fn a_drawing_too_large_to_reopen_is_not_saved() {
assert_eq!(Drawing::from_json(&small.to_json().unwrap()).unwrap(), small);
}
/// A new drawing has one sheet, its id the source's first, follows its designs' units and renders
/// pictures at 300 dpi; a name, language or border that breaks the rules is refused.
/// A new drawing has one sheet, the drawing's id the source's first and the sheet's the next,
/// follows its designs' units and renders pictures at 300 dpi; its id is none of its design's other
/// drawings'; a name, language or border that breaks the rules is refused.
#[test]
fn a_new_drawing_has_one_sheet_and_keeps_the_rules() {
let d = bare();
let first = IdSource::seeded(SEED).next(&BTreeSet::new()).unwrap();
assert_eq!((d.sheets.len(), d.sheets[0].id, d.sheets[0].dpi, &d.units), (1, first, DEFAULT_DPI, &Units::FollowModel));
let mut seeded = IdSource::seeded(SEED);
let first = seeded.next(&BTreeSet::new()).unwrap();
let second = seeded.next(&BTreeSet::new()).unwrap();
assert_eq!((d.id, d.sheets.len(), d.sheets[0].id, d.sheets[0].dpi, &d.units), (first, 1, second, DEFAULT_DPI, &Units::FollowModel));
assert_eq!((d.format, d.format_pending), (DRAWING.format_string(DRAWING.current()), DRAWING.pending_names()));
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
let beside = Drawing::new("Beside", "en", "Sheet", a4, None, &BTreeSet::from([first]), &mut IdSource::seeded(SEED)).unwrap();
assert!(beside.id != first && beside.sheets[0].id != beside.id, "the design's drawing keeps its id");
let thin = DrawingBorderProfile { drawing_border_line_mm: 0.0, ..DrawingBorderProfile::iso() };
let broken = [("", "en", "Sheet", None), ("Bare", "en_GB", "Sheet", None), ("Bare", "en", "\n", None), ("Bare", "en", "Sheet", Some(thin))];
for (name, language, sheet, border) in broken {
let made = Drawing::new(name, language, sheet, a4, border, &mut IdSource::seeded(SEED));
let made = Drawing::new(name, language, sheet, a4, border, &BTreeSet::new(), &mut IdSource::seeded(SEED));
assert!(made.is_err(), "{name:?} {language:?} {sheet:?} {border:?}");
}
}
@@ -518,6 +646,109 @@ fn mutated_files_give_an_error_or_a_valid_drawing_never_a_panic() {
assert!(read_back > 50, "only {read_back} mutated files read: the mutations hardly reach the record's rules");
}
/// A design holding drawings, as plain JSON: each drawing inline under `drawings`, as its own
/// record with its own format. While the change is pending the design names `drawings` in its
/// `format_pending`, so a build without it refuses the design (`format::tests`) rather than open it
/// and save it without them. They read back in their order, by name, then id, and save as the same
/// bytes; a design without drawings writes no `drawings`.
#[test]
fn a_design_keeps_its_drawings_inline_in_plain_json() {
let named = |name: &str, id: u64| Arc::new(Drawing { name: name.into(), id, ..plate() });
let mut doc = crate::Document::new("Plate");
doc.drawings = vec![named("Details", 7), Arc::new(plate()), named("Details", 3), named("Assembly", u64::MAX)];
let read = crate::Document::from_json(&doc.to_json()).unwrap();
let order: Vec<_> = read.drawings.iter().map(|d| (d.name.as_str(), d.id)).collect();
assert_eq!(order, [("Assembly", u64::MAX), ("Details", 3), ("Details", 7), ("Plate assembly", plate().id)]);
let saved = read.to_json();
assert_eq!(crate::Document::from_json(&saved).unwrap().to_json(), saved, "save, open, save: the same bytes");
let v: Value = serde_json::from_str(&saved).unwrap();
assert_eq!(v["drawings"][3], file(&plate()));
assert!(v["format_pending"].as_array().is_some_and(|names| names.contains(&json!("drawings"))), "{}", v["format_pending"]);
let empty: Value = serde_json::from_str(&crate::Document::new("Plate").to_json()).unwrap();
assert_eq!(empty.get("drawings"), None);
}
/// What a design refuses in its drawings, each time the whole design, never by dropping a drawing:
/// one a newer build saved (a newer number, or a change this build doesn't know), a damaged one,
/// `drawings` that isn't a list, two with one id, and more than a design may hold, counted before
/// any is read. A drawing's numbers are its own family's to check: other tools' data in its
/// properties is kept, a sheet's number out of range refused.
#[test]
fn a_drawing_a_design_cant_read_refuses_the_design() {
let mut doc = crate::Document::new("Plate");
doc.drawings = vec![Arc::new(plate())];
let design: Value = serde_json::from_str(&doc.to_json()).unwrap();
let refused = |edit: &dyn Fn(&mut Value)| {
let mut v = design.clone();
edit(&mut v);
crate::Document::from_json(&v.to_string()).map_or_else(|e| e.to_string(), |d| format!("read {}", d.drawings.len()))
};
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
assert_eq!(
refused(&|v| v["drawings"][0]["format"] = json!(DRAWING.format_string(newer))),
format!(
"drawing 1: this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it"
)
);
let later = refused(&|v| v["drawings"][0]["format_pending"] = json!(["a-later-change"]));
assert!(if DRAWING.pending_names().is_empty() { later == "read 1" } else { later.contains("newer SolidCraft") }, "{later}");
let damaged = refused(&|v| v["drawings"][0]["language"] = json!("e"));
assert!(damaged.starts_with("drawing 1: damaged drawing: the language"), "{damaged}");
assert_eq!(refused(&|v| v["drawings"] = json!({})), "damaged design: `drawings` is not a list");
let twice = refused(&|v| {
let again = v["drawings"][0].clone();
v["drawings"].as_array_mut().unwrap().push(again);
});
assert_eq!(twice, format!("damaged design: two drawings have the id {}", ids::encode(plate().id)));
let at_cap: Vec<Value> = (1..=MAX_DRAWINGS as u64)
.map(|id| {
let mut d = file(&plate());
d["id"] = json!(ids::encode(id));
d
})
.collect();
assert_eq!(refused(&|v| v["drawings"] = Value::Array(at_cap.clone())), format!("read {MAX_DRAWINGS}"));
let over = refused(&|v| v["drawings"] = Value::Array(vec![json!({}); MAX_DRAWINGS + 1]));
assert_eq!(over, format!("damaged design: a design holds at most {MAX_DRAWINGS} drawings"));
assert_eq!(refused(&|v| v["drawings"][0]["properties"]["com.example"] = json!({"huge": 1e300})), "read 1");
let out_of_range = refused(&|v| v["drawings"][0]["sheets"][0]["dpi"] = json!(1e300));
assert!(out_of_range.starts_with("drawing 1: damaged drawing: the number "), "{out_of_range}");
}
/// A copy that must open again (an autosave, a crash report's design) keeps every drawing a read
/// takes: one breaking its own rules, the later of two with one id and those past the cap are left
/// out, each named with its rule.
#[test]
fn a_copy_that_must_open_leaves_out_only_the_drawings_a_read_refuses() {
let numbered = |id: u64| Arc::new(Drawing { id, name: format!("Plate {id}"), ..plate() });
let mut doc = crate::Document::new("Plate");
doc.drawings = vec![numbered(1), numbered(2)];
assert!(matches!(openable(&doc), (Cow::Borrowed(_), ref left_out) if left_out.is_empty()));
let broken = Arc::new(Drawing { language: "?".into(), ..(*numbered(3)).clone() });
let again = Arc::new(Drawing { name: "Plate 1 again".into(), ..(*numbered(1)).clone() });
doc.drawings = vec![numbered(1), broken, again, numbered(4)];
let (kept, left_out) = openable(&doc);
assert_eq!(kept.drawings, [numbered(1), numbered(4)]);
assert_eq!(left_out.len(), 2);
let (three, one) = (ids::encode(3), ids::encode(1));
assert!(left_out[0].starts_with(&format!(r#"drawing "Plate 3" ({three}) left out: the language is not"#)), "{left_out:?}");
assert_eq!(left_out[1], format!(r#"drawing "Plate 1 again" ({one}) left out: two drawings have the id {one}"#));
// A drawing left out takes no id from a later one.
let broken_first = Arc::new(Drawing { language: "?".into(), ..(*numbered(1)).clone() });
doc.drawings = vec![broken_first, numbered(1)];
let (kept, left_out) = openable(&doc);
assert_eq!(kept.drawings, [numbered(1)]);
assert_eq!(left_out.len(), 1, "{left_out:?}");
doc.drawings = (1..=MAX_DRAWINGS as u64 + 2).map(numbered).collect();
let (kept, left_out) = openable(&doc);
assert_eq!(kept.drawings, doc.drawings[..MAX_DRAWINGS]);
let over = |id: u64| format!(r#"drawing "Plate {id}" ({}) left out: a design holds at most {MAX_DRAWINGS} drawings"#, ids::encode(id));
assert_eq!(left_out, [over(MAX_DRAWINGS as u64 + 1), over(MAX_DRAWINGS as u64 + 2)]);
}
/// The number of values in `v`, itself included.
fn count(v: &Value) -> usize {
1 + match v {
+132
View File
@@ -0,0 +1,132 @@
//! A drawing view's settings (W07, #5866): which way it looks, its scale and how it draws.
//! `drawing.view.snapshot` takes them whole; a saved view (W12) keeps the same fields, each one
//! optional so that a projected view can follow its parent.
use serde::{Deserialize, Deserializer, Serialize};
use solidcraft_geom::Plane;
use solidcraft_geom::drawing_view::{Invalid, NamedView, Scale, basis_looking};
use solidcraft_render::VisualStyle;
/// The most of its reference parts a view shows, as a percent.
pub const MAX_REFERENCE_SHOWN_PERCENT: f64 = 100.0;
/// Which way a view looks: by name, or along any direction with an up.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(untagged)]
pub enum ViewDirection {
Named(NamedView),
Looking(Looking),
}
/// A view along `direction` with `up` toward the top of the paper, through `origin` (the world
/// origin when not given).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Looking {
pub direction: [f64; 3],
pub up: [f64; 3],
#[serde(default)]
pub origin: [f64; 3],
}
impl<'de> Deserialize<'de> for ViewDirection {
/// One message for anything that is neither, rather than serde's "did not match any variant".
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<ViewDirection, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Either {
Named(NamedView),
Looking(Looking),
}
let message = "view is front, back, top, bottom, left, right, iso, iso_front_right, iso_front_left, iso_back_right, iso_back_left, or {direction, up, origin?}";
Ok(match Either::deserialize(d).map_err(|_| serde::de::Error::custom(message))? {
Either::Named(view) => ViewDirection::Named(view),
Either::Looking(looking) => ViewDirection::Looking(looking),
})
}
}
impl Default for ViewDirection {
fn default() -> Self {
ViewDirection::Named(NamedView::Front)
}
}
impl ViewDirection {
/// The view's basis: a named view's exact one, or one built from the direction and up.
pub fn basis(&self) -> Result<Plane, Invalid> {
match *self {
ViewDirection::Named(view) => Ok(view.basis()),
ViewDirection::Looking(Looking { direction, up, origin }) => basis_looking(direction.into(), up.into(), origin.into()),
}
}
}
/// Tangent edges, where faces meet smoothly: left out (the default) or drawn.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TangentEdges {
#[default]
Hide,
Show,
}
/// What a view shows and how. Every field has a default: the front view at the scale that fits
/// its page, visible lines only, tangent edges hidden.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ViewSettings {
pub view: ViewDirection,
/// None: the largest preset scale at which the view fits its page ([`Scale::fit`]). Not
/// written when None: a snapshot refuses a null scale.
#[serde(skip_serializing_if = "Option::is_none")]
pub scale: Option<Scale>,
pub display_style: VisualStyle,
/// How reference parts draw (bill of materials type `reference`).
pub reference_style: VisualStyle,
/// Whether reference parts hide the model's lines behind them.
pub reference_hides_model: bool,
/// How far the view's boundary grows from the model's box towards the box that also holds its
/// reference parts, each side on its own: 0 to [`MAX_REFERENCE_SHOWN_PERCENT`].
pub reference_shown_percent: f64,
pub tangent_edges: TangentEdges,
}
/// The settings a saved view sets itself, each on its own: one left out follows the view's parent,
/// then [`ViewSettings`]'s default ([`crate::drawing::Drawing::resolve_view`]). None is written.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ViewOverrides {
#[serde(skip_serializing_if = "Option::is_none")]
pub scale: Option<Scale>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_style: Option<VisualStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_style: Option<VisualStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference_hides_model: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tangent_edges: Option<TangentEdges>,
}
impl ViewOverrides {
/// Sets nothing: every setting follows.
pub fn is_empty(&self) -> bool {
*self == ViewOverrides::default()
}
}
impl ViewSettings {
/// What reading alone can't check: the percent's range and the view's basis.
pub fn validate(&self) -> Result<Plane, Invalid> {
let percent = self.reference_shown_percent;
if !(percent.is_finite() && (0.0..=MAX_REFERENCE_SHOWN_PERCENT).contains(&percent)) {
return Err(Invalid("reference_shown_percent is a number from 0 to 100"));
}
self.view.basis()
}
}
#[cfg(test)]
#[path = "drawing_view_tests.rs"]
mod tests;
+55
View File
@@ -0,0 +1,55 @@
use serde_json::json;
use solidcraft_geom::Vec3;
use super::*;
/// Every field has its default, and the settings read and write back the same.
#[test]
fn view_settings_default_to_a_front_view_that_fits_its_page() {
let settings: ViewSettings = serde_json::from_value(json!({})).unwrap();
assert_eq!(settings, ViewSettings::default());
assert_eq!(settings.view, ViewDirection::Named(NamedView::Front));
assert_eq!((settings.scale, settings.display_style, settings.tangent_edges), (None, VisualStyle::Visible, TangentEdges::Hide));
assert_eq!(settings.validate().unwrap(), NamedView::Front.basis());
assert_eq!(serde_json::to_value(settings).unwrap().get("scale"), None, "no scale is written, not null");
let full = json!({
"view": {"direction": [0.0, 1.0, 0.0], "up": [0.0, 0.0, 1.0], "origin": [1.0, 2.0, 3.0]},
"scale": "1:2", "display_style": "visible_hidden", "reference_style": "shaded",
"reference_hides_model": true, "reference_shown_percent": 5.0, "tangent_edges": "show",
});
let read: ViewSettings = serde_json::from_value(full.clone()).unwrap();
assert_eq!(serde_json::to_value(read).unwrap(), full);
assert_eq!(read.validate().unwrap().origin, Vec3::new(1.0, 2.0, 3.0));
let iso: ViewSettings = serde_json::from_value(json!({"view": "iso"})).unwrap();
assert_eq!(iso.view, ViewDirection::Named(NamedView::IsoFrontRight));
}
/// What the settings refuse: unknown fields, unknown names, a basis that can't be built and a
/// margin out of range.
#[test]
fn view_settings_refuse_what_they_cant_draw() {
for not_settings in [
json!({"scael": "1:2"}),
json!({"view": 3}),
json!({"view": "Front"}),
json!({"view": {"direction": [0, 1, 0]}}),
json!({"view": {"direction": [0, 1, 0], "up": [0, 0, 1], "roll": 1}}),
json!({"scale": 0.5}),
json!({"display_style": "hidden"}),
json!({"tangent_edges": "thin"}),
] {
assert!(serde_json::from_value::<ViewSettings>(not_settings.clone()).is_err(), "{not_settings}");
}
let refused = [
json!({"view": {"direction": [0, 0, 0], "up": [0, 0, 1]}}),
json!({"view": {"direction": [0, 0, 1], "up": [0, 0, -1]}}),
json!({"reference_shown_percent": -0.001}),
json!({"reference_shown_percent": 100.001}),
];
for settings in refused {
let read: ViewSettings = serde_json::from_value(settings.clone()).unwrap();
assert!(read.validate().is_err(), "{settings}");
}
let edge: ViewSettings = serde_json::from_value(json!({"reference_shown_percent": 100.0})).unwrap();
assert!(edge.validate().is_ok());
}
+32
View File
@@ -67,6 +67,8 @@ pub struct ModelBody {
/// [`Body::outline_bounds`] of `body` (with the body it was measured on), worked out once
/// and shared by clones.
outline: Arc<OnceLock<(Body, solidcraft_geom::Aabb3)>>,
/// [`ModelBody::volume`] (with the body it was measured on), measured once and shared by clones.
volume: Arc<OnceLock<(Body, Option<f64>)>>,
/// [`kernel::measure`] of `body` (with the body it was measured on), worked out once and
/// shared by clones: inspecting a design again measures only the bodies that changed.
measured: Arc<OnceLock<(Body, kernel::Result<kernel::BodyMeasure>)>>,
@@ -97,11 +99,24 @@ impl ModelBody {
bytes: 0,
source_key: 0,
outline: Arc::new(OnceLock::new()),
volume: Arc::new(OnceLock::new()),
measured: Arc::new(OnceLock::new()),
surface: Arc::new(OnceLock::new()),
}
}
/// The body's volume in mm³ ([`kernel::measure_mass`]), measured once and shared by clones: a
/// body's measure depends on nothing else, so the bill of materials and the physical
/// properties weigh it once however often they are asked. `None` when it can't be measured.
pub fn volume(&self) -> Option<f64> {
let measure = || kernel::measure_mass(&self.body).ok().map(|m| m.volume);
match self.volume.get_or_init(|| (self.body.clone(), measure())) {
(measured, v) if measured.same(&self.body) => *v,
// The body was replaced in this cell's holder since: measure it again.
_ => measure(),
}
}
/// A surface body: some shell does not close ([`Body::is_surface`]), worked out once.
pub fn is_surface(&self) -> bool {
match self.surface.get_or_init(|| (self.body.clone(), self.body.is_surface())) {
@@ -252,6 +267,23 @@ impl ModelBody {
})
}
/// The box of [`ModelBody::mesh`] seen on a plane, in the plane's own coordinates
/// ([`Plane::to_local`]): a drawing view's extents (W07), again without making a placement's
/// moved copy.
pub fn mesh_bounds_in(&self, plane: &Plane) -> solidcraft_geom::Aabb2 {
let (mesh, m) = match self.cached_mesh() {
Some(mesh) => (mesh, crate::IDENTITY),
None => self.mesh_in_place(),
};
// The placement folded into each of the plane's axes: a mesh point p lands at
// p · axis + offset, with no moved copy of the point made first.
let [c0, c1, c2, t] = m.map(|r| Vec3::new(r[0], r[1], r[2]));
let offset = t - plane.origin;
let axis = |a: Vec3| (Vec3::new(c0.dot(a), c1.dot(a), c2.dot(a)), offset.dot(a));
let ((ax, ox), (ay, oy)) = (axis(plane.x), axis(plane.y));
mesh.positions.iter().map(|p| Vec2::new(p.dot(ax) + ox, p.dot(ay) + oy)).collect()
}
/// [`ModelBody::plan_mesh_at`] as a shared mesh and the move that places it, as
/// [`ModelBody::mesh_in_place`] does for the reference mesh.
pub fn plan_mesh_in_place_at(&self, q: crate::display_quality::DisplayQuality) -> Option<(Arc<Mesh>, crate::Mat)> {
+1 -2
View File
@@ -157,8 +157,7 @@ pub enum FaceAttachmentStatus {
}
fn map_plane(matrix: &crate::Mat, plane: &Plane) -> Result<Plane> {
Plane::new(crate::apply_point(matrix, plane.origin), crate::apply_vector(matrix, plane.x), crate::apply_vector(matrix, plane.y))
.ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
crate::map_plane(matrix, plane).ok_or_else(|| DocError::Invalid("attached plane transform is degenerate".into()))
}
impl Document {
+14 -1
View File
@@ -677,6 +677,8 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
pub fn read(s: &str) -> Result<Document> {
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft design: {e}")))?;
let (version, applied) = DESIGN.take_version(&mut v)?;
// Each drawing is a record of its own family, read by its own rules, numbers and all.
let drawings = take_drawings(&mut v)?;
check_design_numbers(&v)?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
@@ -798,11 +800,22 @@ pub fn read(s: &str) -> Result<Document> {
// These lists used to allocate independently. Keep their existing identities, but never
// let a later deletion make them available again after loading an older design.
d.next_id = d.next_item_id()?;
d.drawings = crate::drawing::in_design(drawings).map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
d.format = format_string(FORMAT);
d.format_pending = pending_names();
Ok(d)
}
/// Take a plain JSON design's drawings out of its JSON and read each, before the design's own
/// rules see anything: a drawing this build can't read refuses the design, never dropped.
fn take_drawings(v: &mut Value) -> Result<Vec<crate::drawing::Drawing>> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove("drawings")) else { return Ok(Vec::new()) };
let Value::Array(list) = list else { return Err(DocError::Invalid("damaged design: `drawings` is not a list".into())) };
crate::drawing::check_count(list.len()).map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
let read = |(i, d): (usize, Value)| crate::drawing::Drawing::from_value(d).map_err(|e| DocError::Invalid(format!("drawing {}: {e}", i + 1)));
list.into_iter().enumerate().map(read).collect()
}
fn all_unique(ids: impl Iterator<Item = u64>) -> bool {
let mut seen = std::collections::BTreeSet::new();
ids.into_iter().all(|id| seen.insert(id))
@@ -1155,7 +1168,7 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
/// Largest coordinate or size a design may hold (mm): a kilometre-scale part is already far
/// outside what the kernel's tolerances serve.
const MAX_REAL: f64 = 1e9;
pub(crate) const MAX_REAL: f64 = 1e9;
/// Every number the design itself holds is in range ([`check_numbers`]). `metadata` is left
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
@@ -0,0 +1,6 @@
//! A bill of materials view can be numbered (`assembly.bom.renumber`, `assembly.bom.item.set`):
//! its rows' item numbers are saved in `assembly.item_numbers`, one entry for each view numbered
//! (its assembly, view and list options) with each row's number and lock under the row's key.
//! Older designs need no transformation: a design without it has no view numbered, and every
//! number is provisional as before. The last release asks for an update instead of dropping it on
//! save.
@@ -0,0 +1,5 @@
//! A design holds its drawings (#3890): `drawings`, each a drawing record of its own family
//! (`solidcraft-drawing/N`, `crate::drawing`). A design container keeps each drawing in its own
//! entry, `drawings/<id>.json`, beside `document.json`; a plain JSON design keeps them inline.
//! Older designs need no transformation: they hold no drawings. The last release refuses a design
//! saved with this (the newer format number), so it never opens one and saves it without them.
@@ -0,0 +1,5 @@
//! An occurrence can count in the bill of materials as another type than its component
//! (`bom_type` on the occurrence: `normal`, `phantom`, `reference`, `purchased` or `inseparable`),
//! for that occurrence only; `occurrence.bom_type` sets it. Older designs need no transformation:
//! an occurrence without it counts as its component. The last release asks for an update instead
//! of dropping it on save.
+12 -77
View File
@@ -9,24 +9,19 @@
//! use and drawn again when it is one. [`IdSource::seeded`] gives the same ids for the same
//! seed, for tests and replays, and still a new one in a file that seed made;
//! [`IdSource::from_entropy`] is what a session uses.
//! - In a file an id is 13 characters of Crockford's base32, lowercase: digits and letters without
//! i, l, o and u, the first `0` to `f` (`0000000000001`, `fzzzzzzzzzzzz`). [`encode`] writes it
//! and [`decode`] reads only that spelling, so an id has one form in a file's text. A field
//! holding an id says `#[serde(with = "crate::ids")]`.
//! - In a file an id is 13 characters of Crockford's base32, lowercase: [`encode`] writes it and
//! [`decode`] reads only that spelling ([`solidcraft_geom::ids`], where data the geometry crate
//! defines can use it too). A field holding an id says `#[serde(with = "crate::ids")]`.
use std::collections::BTreeSet;
use std::collections::hash_map::RandomState;
use std::hash::BuildHasher;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Deserializer, Serializer};
pub use solidcraft_geom::ids::{ID_CHARS, decode, deserialize, encode, serialize};
use crate::{DocError, Result};
/// The characters of an id in a file: 64 bits in 5-bit digits, the first holding the top four.
pub const ID_CHARS: usize = 13;
/// Crockford's base32 digits, lowercase.
const DIGITS: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
/// splitmix64's step: odd, so the state passes every `u64` before it comes round again.
const GAMMA: u64 = 0x9e37_79b9_7f4a_7c15;
@@ -51,6 +46,12 @@ impl IdSource {
IdSource::seeded(RandomState::new().hash_one((nanos, MADE.fetch_add(1, Ordering::Relaxed))))
}
/// Where the source stands: [`IdSource::seeded`] with it gives the ids this one gives next
/// (a replay's start).
pub fn seed(&self) -> u64 {
self.state
}
/// The next id that isn't 0 and isn't `in_use`. A run never draws a number twice, so at most
/// one draw per id in use, and one 0, come before a free one.
pub fn next(&mut self, in_use: &BTreeSet<u64>) -> Result<u64> {
@@ -73,32 +74,6 @@ impl IdSource {
}
}
/// `id` as a file writes it: [`ID_CHARS`] base32 digits, the most significant first.
pub fn encode(id: u64) -> String {
(0..ID_CHARS).rev().map(|k| DIGITS.get(((id >> (5 * k)) & 31) as usize).map_or('0', |&d| char::from(d))).collect()
}
/// The id `text` writes, if it is one in [`encode`]'s spelling: anything else, 0 included, is not.
pub fn decode(text: &str) -> Option<u64> {
if text.len() != ID_CHARS {
return None;
}
// A first digit above `f` overflows the 64 bits.
let id = text.bytes().try_fold(0u64, |id, b| id.checked_mul(32)?.checked_add(DIGITS.iter().position(|&d| d == b)? as u64))?;
(id != 0).then_some(id)
}
/// serde's `with` for an id field: written as [`encode`] writes it, read as [`decode`] reads it.
pub fn serialize<S: Serializer>(id: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&encode(*id))
}
/// See [`serialize`].
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<u64, D::Error> {
let text = String::deserialize(d)?;
decode(&text).ok_or_else(|| serde::de::Error::custom(format!("an id is {ID_CHARS} characters of lowercase Crockford base32, not all 0")))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -107,21 +82,9 @@ mod tests {
BTreeSet::new()
}
/// Every boundary of the spelling: 1, the last value of one digit and the first of two, the
/// top digit alone and the largest id, and splitmix64's first output for seed 0.
/// Every id a source gives is written and read back as itself.
#[test]
fn an_id_is_written_as_13_digits_and_read_back() {
let pinned = [
(1, "0000000000001"),
(31, "000000000000z"),
(32, "0000000000010"),
(1 << 60, "1000000000000"),
(u64::MAX, "fzzzzzzzzzzzz"),
(0xe220_a839_7b1d_cdaf, "e485875xhvkdf"),
];
for (id, text) in pinned {
assert_eq!((encode(id), decode(text)), (text.to_string(), Some(id)), "{id:#x}");
}
fn a_sources_ids_are_written_and_read_back() {
let mut source = IdSource::seeded(1);
for _ in 0..10_000 {
let id = source.next(&none()).unwrap();
@@ -129,34 +92,6 @@ mod tests {
}
}
/// Only [`encode`]'s spelling is an id: the wrong length, 0, a first digit past `f`, capitals
/// (case-sensitive, so an id has one spelling to search for), the letters Crockford leaves
/// out, and anything not ASCII.
#[test]
fn only_the_written_spelling_is_an_id() {
let not_ids = [
"",
"000000000001",
"00000000000001",
"0000000000000",
"g000000000000",
"zzzzzzzzzzzzz",
"Fzzzzzzzzzzzz",
"000000000000A",
"000000000000i",
"000000000000l",
"000000000000o",
"000000000000u",
"00000000000é",
"000000000000-",
" 000000000001",
"000000000001\n",
];
for text in not_ids {
assert_eq!(decode(text), None, "{text:?}");
}
}
/// A seed gives the same ids every time (splitmix64's published first outputs for seed 0), and
/// a run never gives one twice.
#[test]
+14 -3
View File
@@ -624,6 +624,9 @@ pub struct Assembly {
pub traces: Vec<crate::motion::MotionTrace>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exploded: Vec<crate::motion::ExplodedView>,
/// The bill of materials views numbered, with their saved item numbers.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub item_numbers: Vec<crate::bom::ItemNumbers>,
}
impl Assembly {
@@ -698,6 +701,7 @@ impl Assembly {
&& self.studies.is_empty()
&& self.traces.is_empty()
&& self.exploded.is_empty()
&& self.item_numbers.is_empty()
}
}
@@ -2111,7 +2115,7 @@ impl Document {
pub mod cam;
#[path = "joints_edge.rs"]
mod edge;
pub(crate) mod edge;
pub use edge::{edge_axis, edge_snap};
#[path = "joints_reach.rs"]
@@ -2450,8 +2454,15 @@ mod tests {
/// values land on their joints; a value for a missing joint is skipped.
#[test]
fn a_solution_places_occurrences_in_their_moved_parents() {
let occurrence =
|id, component, parent| crate::Occurrence { id, component, parent, name: format!("O{id}"), transform: IDENTITY, grounded: false };
let occurrence = |id, component, parent| crate::Occurrence {
id,
component,
parent,
name: format!("O{id}"),
transform: IDENTITY,
grounded: false,
bom_type: None,
};
// Component 1 sits at the root twice (its first occurrence is the frame its children sit
// in), component 2 inside 1 and component 3 inside 2.
let mut doc = Document {
+9 -1
View File
@@ -941,7 +941,15 @@ mod tests {
suppressed: false,
exprs: Default::default(),
};
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
let occ = |id: u64, grounded: bool| crate::Occurrence {
id,
component: id,
parent: 0,
name: format!("O{id}"),
transform: IDENTITY,
grounded,
bom_type: None,
};
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
let mut doc = Document { occurrences: (1..=10).map(|id| occ(id, id == 1)).collect(), ..Document::default() };
let (r, v, s) = (JointKind::Rigid, JointKind::Revolute, JointKind::Slider);
+1 -1
View File
@@ -67,7 +67,7 @@ fn face_of(b: &ModelBody, i: usize, name: Option<&str>, pick: Vec3) -> Result<(u
}
/// Edge `i`'s middle (along its polyline) and the direction it runs there.
fn middle(e: &[Vec3]) -> Option<(Vec3, Vec3)> {
pub(crate) fn middle(e: &[Vec3]) -> Option<(Vec3, Vec3)> {
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
let mut acc = 0.0;
for w in e.windows(2) {
+4 -2
View File
@@ -10,6 +10,7 @@ pub mod appearance_pack;
mod assembly;
mod body_transfer;
mod body_transfer_sheet;
pub mod bom;
pub mod pbr_appearance;
pub use body_transfer::{valid_transfer_matrix, validate_body_transfer};
pub mod canvas;
@@ -24,6 +25,7 @@ pub mod display_quality;
pub mod display_units;
mod document;
pub mod drawing;
pub mod drawing_view;
mod eval;
pub mod expr;
pub mod face_anchor;
@@ -75,8 +77,8 @@ pub mod sketch_equation;
pub mod text_params;
pub use assembly::{
BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, map_point_expr,
map_vector_expr, mat_inverse, mat_mul, rigid,
BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, map_plane,
map_point_expr, map_vector_expr, mat_inverse, mat_mul, mat_volume_scale, rigid,
};
pub use document::*;
pub use eval::{
+109 -9
View File
@@ -6,7 +6,9 @@
//! each may be set (the design, a component or both), how its value is checked and its label.
//! It keeps every other key exactly as it was through open, edit and save. Other tools keep
//! their own data under one reverse-DNS key each (`com.example.tool`), holding an object.
//! A component's part properties have one more key: its bill of materials type (`bom_type`).
//! A component's part properties have four more keys ([`PART_ONLY`]): its bill of materials type
//! (`bom_type`), its static material (`material`), its quantity (`bom_quantity`) and whether it
//! is virtual (`virtual`).
//!
//! Reading never validates: a well-known key holding something other than text is kept as it
//! is and reads as absent, so a file another tool wrote always opens. Writing through
@@ -98,6 +100,25 @@ pub static BOM_KEYS: &[&str] = &[PART_NUMBER, DESCRIPTION, REVISION];
/// doesn't list it and a design refuses it.
pub const BOM_TYPE: &str = "bom_type";
/// A component's static material: what its bill of materials rows say it is made of, typed in by
/// hand in place of the materials worked out from its bodies (any text). It names, it doesn't
/// weigh: mass still comes from the bodies' materials.
pub const MATERIAL: &str = "material";
/// What one placement of a component means in a bill of materials: an expression evaluated with
/// the design's parameters, a length or (without a unit) a number of pieces. Without it, a
/// placement is one piece. The bill of materials evaluates it (`bom::Amount`).
pub const BOM_QUANTITY: &str = "bom_quantity";
/// A virtual component: one with no geometry (grease, paint, a label), listed in the bill of
/// materials with its quantity. It holds `true`; it never holds bodies, sketches or placed
/// components.
pub const VIRTUAL: &str = "virtual";
/// The part properties only a component holds, none of them a row of [`KEYS`]: the Design
/// Properties dialog doesn't list them and `document.metadata.set` refuses them.
pub const PART_ONLY: [&str; 4] = [BOM_TYPE, MATERIAL, BOM_QUANTITY, VIRTUAL];
/// Longest key, in bytes.
pub const MAX_KEY_BYTES: usize = 128;
/// Longest well-known text value, in bytes.
@@ -120,10 +141,10 @@ pub fn keys(owner: Owner) -> impl Iterator<Item = &'static Key> {
KEYS.iter().filter(move |k| k.scope.covers(owner))
}
/// The names of [`keys`]`(owner)`, and a component's [`BOM_TYPE`], as refusals list them.
/// The names of [`keys`]`(owner)`, and a component's [`PART_ONLY`] keys, as refusals list them.
pub fn key_list(owner: Owner) -> String {
let bom_type = (owner == Owner::Component).then_some(BOM_TYPE);
keys(owner).map(|k| k.name).chain(bom_type).collect::<Vec<_>>().join(", ")
let part_only = PART_ONLY.iter().copied().filter(|_| owner == Owner::Component);
keys(owner).map(|k| k.name).chain(part_only).collect::<Vec<_>>().join(", ")
}
/// One of SolidCraft's keys, by name.
@@ -133,7 +154,7 @@ pub fn key(name: &str) -> Option<&'static Key> {
/// A key SolidCraft reads as a design or part property.
pub fn is_well_known(name: &str) -> bool {
key(name).is_some() || name == BOM_TYPE
key(name).is_some() || PART_ONLY.contains(&name)
}
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
@@ -248,6 +269,18 @@ impl Metadata {
pub fn bom_type(&self) -> BomType {
self.text(BOM_TYPE).and_then(BomType::parse).unwrap_or_default()
}
/// The component's static material, when one is typed in.
pub fn material(&self) -> Option<&str> {
self.text(MATERIAL)
}
/// The component's quantity expression, when it has one.
pub fn bom_quantity(&self) -> Option<&str> {
self.text(BOM_QUANTITY)
}
/// The component is virtual: anything but `true` reads as not.
pub fn is_virtual(&self) -> bool {
self.0.get(VIRTUAL) == Some(&Value::Bool(true))
}
/// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added),
/// kept as they are.
@@ -285,8 +318,19 @@ impl Metadata {
/// `value` as `key` may hold it in `owner`'s metadata (`None`: remove the key), or why it can't.
fn check(owner: Owner, key: &str, value: Value) -> Result<Option<Value>> {
if key == BOM_TYPE && owner == Owner::Component {
return bom_type(value);
if owner == Owner::Component {
match key {
BOM_TYPE => return bom_type(value),
VIRTUAL => {
return match value {
Value::Bool(true) => Ok(Some(value)),
Value::Bool(false) | Value::Null => Ok(None),
_ => Err(invalid(format!("`{VIRTUAL}` is true or false"))),
};
}
MATERIAL | BOM_QUANTITY => return text(key, value),
_ => {}
}
}
if let Some(k) = self::key(key).filter(|k| k.scope.covers(owner)) {
return (k.check)(key, value);
@@ -385,6 +429,57 @@ mod tests {
assert!(Metadata::default().set(Design, BOM_TYPE, json!("phantom")).is_err(), "a design has no bill of materials type");
}
#[test]
fn a_static_material_takes_any_text_and_reads_as_absent_otherwise() {
let mut m = Metadata::default();
assert_eq!(m.material(), None);
let longest = "é".repeat(MAX_TEXT_BYTES / 2);
for text in ["Stainless steel 1.4301", "Mixed: see drawing", "鋼", longest.as_str()] {
assert!(m.set(Component, MATERIAL, json!(text)).unwrap(), "{text}");
assert_eq!(m.material(), Some(text));
}
for bad in
[json!("x".repeat(MAX_TEXT_BYTES + 1)), json!(7.85), json!({"name": "Steel"}), json!(["Steel"]), json!(format!("a{}b", char::from(7)))]
{
assert!(m.set(Component, MATERIAL, bad.clone()).is_err(), "{bad}");
}
assert_eq!(m.material(), Some(longest.as_str()), "a refused value leaves the old one");
assert!(m.set(Component, MATERIAL, Value::Null).unwrap(), "null removes it");
assert_eq!(m.material(), None);
// Another tool's number is kept as it is and reads as no static material.
let other: Metadata = serde_json::from_value(json!({"material": 42})).unwrap();
assert_eq!((other.material(), other.get(MATERIAL)), (None, Some(&json!(42))));
assert!(other.others().next().is_none(), "SolidCraft reads it, so it isn't another tool's key");
assert!(!keys(Design).any(|k| k.name == MATERIAL), "the Design Properties dialog doesn't list it");
assert!(Metadata::default().set(Design, MATERIAL, json!("Steel")).is_err(), "a design has no static material");
}
#[test]
fn virtual_takes_true_or_false_and_a_quantity_takes_text() {
let mut m = Metadata::default();
assert!(!m.is_virtual());
assert!(m.set(Component, VIRTUAL, json!(true)).unwrap());
assert!(m.is_virtual());
assert_eq!(m.get(VIRTUAL), Some(&json!(true)));
for bad in [json!("true"), json!(1), json!({}), json!([true])] {
assert!(m.set(Component, VIRTUAL, bad.clone()).is_err(), "{bad}");
}
assert!(m.is_virtual(), "a refused value leaves the old one");
assert!(m.set(Component, VIRTUAL, json!(false)).unwrap(), "false removes it");
assert!(!m.set(Component, VIRTUAL, Value::Null).unwrap());
assert!(m.get(VIRTUAL).is_none());
// Another tool's text is kept and reads as not virtual.
let other: Metadata = serde_json::from_value(json!({"virtual": "yes"})).unwrap();
assert!(!other.is_virtual());
assert!(m.set(Component, BOM_QUANTITY, json!("2 * span + 50 mm")).unwrap());
assert_eq!(m.bom_quantity(), Some("2 * span + 50 mm"));
assert!(m.set(Component, BOM_QUANTITY, json!(2.5)).is_err(), "an expression is text");
assert!(m.set(Component, BOM_QUANTITY, json!("")).unwrap());
assert_eq!(m.bom_quantity(), None);
assert!(m.others().next().is_none(), "SolidCraft reads both");
assert!(!keys(Design).any(|k| k.name == VIRTUAL || k.name == BOM_QUANTITY), "the Design Properties dialog lists neither");
}
#[test]
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
let mut m = Metadata::default();
@@ -422,7 +517,11 @@ mod tests {
}
// Every key SolidCraft took on the design and on a component before the table still is.
assert_eq!(key_list(Design), "project, part_number, revision, author, organization, description, license");
assert_eq!(key_list(Component), format!("{}, {BOM_TYPE}", key_list(Design)), "a component also takes its bill of materials type");
assert_eq!(
key_list(Component),
format!("{}, {BOM_TYPE}, {MATERIAL}, {BOM_QUANTITY}, {VIRTUAL}", key_list(Design)),
"a component also takes its bill of materials type, static material, quantity and virtual flag"
);
}
#[test]
@@ -454,7 +553,8 @@ mod tests {
assert_eq!(
refusal(Component, "colour"),
"`colour` is not a part property: SolidCraft's are project, part_number, revision, author, organization, \
description, license, bom_type; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
description, license, bom_type, material, bom_quantity, virtual; another tool's data goes under a reverse-DNS key \
such as `com.example.tool`"
);
for owner in [Design, Component] {
let word = owner.names().0;
+83 -37
View File
@@ -8,6 +8,8 @@
//! was worked out from geometry, found where the reference's point no longer lies, is reported:
//! only geometry decided.
use std::sync::Arc;
use solidcraft_geom::{Mesh, Vec3};
use crate::history_names::{Match, Names};
@@ -45,49 +47,93 @@ pub struct EdgeHit {
pub legacy: bool,
}
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
/// between the faces it names (followed through splits and merges of those faces); else the
/// A body's edge names and face names, worked out once for every edge name looked up on it.
pub(crate) struct BodyEdges {
pub(crate) mesh: Arc<Mesh>,
names: Vec<String>,
faces: Arc<Names>,
}
/// The edges of a body a stored edge name matches, before a picked point decides between them.
pub(crate) enum EdgeMatch {
/// The edge with that name, or the one edge between the faces it names.
Edge(usize),
/// Pieces of it: the faces it names meet along several edges now, or only a piece of one of
/// them is left.
Pieces(Vec<usize>),
}
impl BodyEdges {
pub(crate) fn new(b: &ModelBody) -> BodyEdges {
BodyEdges { mesh: b.mesh(), names: edge_names(b), faces: naming::names(b) }
}
/// Whether edge `i`'s name follows exact history: the faces it separates do, and it isn't one
/// of several edges between them, numbered by where they lie.
/// Edge `i`'s persistent name.
pub(crate) fn name(&self, i: usize) -> Option<&str> {
self.names.get(i).map(String::as_str)
}
pub(crate) fn verified(&self, i: usize) -> bool {
self.mesh.edge_faces.get(i).is_some_and(|fs| fs.iter().all(|f| self.faces.verified(*f as usize)))
&& self.names.get(i).is_some_and(|n| strip_edge_number(n).len() == n.len())
}
/// What the stored edge name `name` matches: the edge with that name; else the one edge
/// between the faces it names (followed through splits and merges of those faces); else the
/// pieces of it. `None` when nothing does.
pub(crate) fn matching(&self, name: &str) -> Option<EdgeMatch> {
if let Some(i) = self.names.iter().position(|n| n == name) {
return Some(EdgeMatch::Edge(i));
}
// By the faces it separates: the best way any edge matches.
let wanted = strip_edge_number(name);
let mut best: Option<(Match, Vec<usize>)> = None;
for (i, fs) in self.mesh.edge_faces.iter().enumerate() {
let [fa, fb] = fs.as_slice() else { continue };
let Some(level) = edge_match(&self.faces, *fa as usize, *fb as usize, wanted) else { continue };
keep_best(&mut best, level, i);
}
let (level, cands) = best?;
let one = match cands.as_slice() {
[i] if level >= Match::Piece => Some(*i),
_ => None,
};
Some(one.map_or(EdgeMatch::Pieces(cands), EdgeMatch::Edge))
}
}
/// The edge of a body a stored edge name finds: the edge [`BodyEdges::matching`] gives, else the
/// piece of it nearest `near` (`split`). `Err` explains when there is none.
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
if let Some(old) = legacy_name(name) {
return legacy_locate_edge(b, old, near).map_err(|_| format!("edge `{name}` no longer exists"));
}
let m = b.mesh();
let names = edge_names(b);
let faces = naming::names(b);
let mid = |i: usize| edge_mid(&m, i);
// Several edges between the same two faces are numbered by where they lie: not exact.
let verified = |i: usize| {
m.edge_faces.get(i).is_some_and(|fs| fs.iter().all(|f| faces.verified(*f as usize)))
&& names.get(i).is_some_and(|n| strip_edge_number(n).len() == n.len())
};
let exact = |i: usize| -> Result<EdgeHit, String> {
let on = edge_near(b, &m, i, near);
mid(i)
.map(|p| EdgeHit { edge: i, point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
.ok_or_else(|| "edge".into())
};
if let Some(i) = names.iter().position(|n| n == name) {
return exact(i);
let edges = BodyEdges::new(b);
let m = &edges.mesh;
let gone = || format!("edge `{name}` no longer exists");
match edges.matching(name).ok_or_else(gone)? {
EdgeMatch::Edge(i) => {
let on = edge_near(b, m, i, near);
edge_mid(m, i)
.map(|p| EdgeHit {
edge: i,
point: if on { near } else { p },
split: false,
verified: edges.verified(i),
on_point: on,
legacy: false,
})
.ok_or_else(|| "edge".into())
}
EdgeMatch::Pieces(cands) => cands
.iter()
.filter_map(|i| edge_mid(m, *i).map(|p| (p, *i)))
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: edges.verified(i), on_point: false, legacy: false })
.ok_or_else(gone),
}
// By the faces it separates: the best way any edge matches.
let wanted = strip_edge_number(name);
let mut best: Option<(Match, Vec<usize>)> = None;
for (i, fs) in m.edge_faces.iter().enumerate() {
let [fa, fb] = fs.as_slice() else { continue };
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
keep_best(&mut best, level, i);
}
let (level, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
if let ([i], true) = (cands.as_slice(), level >= Match::Piece) {
return exact(*i);
}
cands
.iter()
.filter_map(|i| mid(*i).map(|p| (p, *i)))
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
.map(|(p, i)| EdgeHit { edge: i, point: p, split: true, verified: verified(i), on_point: false, legacy: false })
.ok_or_else(|| format!("edge `{name}` no longer exists"))
}
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
+15
View File
@@ -623,6 +623,8 @@ pub enum ParamUser {
/// A joint's offset, angle or value, or a motion link's gear, rack or screw length (`input`
/// says which; a link is named by the joint it drives).
Joint { joint: u64, name: String, input: String },
/// A component's quantity in bills of materials ([`crate::metadata::BOM_QUANTITY`]).
Quantity { component: u64, name: String },
/// A sheet metal or plastic rule's value (`kind` says which, `field` which value).
Rule { kind: String, name: String, field: String },
/// The plane a canvas lies on (`input` is its label, e.g. `Offset`).
@@ -640,6 +642,7 @@ impl ParamUser {
None => format!("configuration column {column}"),
},
ParamUser::Joint { name, input, .. } => format!("joint {name} ({input})"),
ParamUser::Quantity { name, .. } => format!("component {name} (quantity)"),
ParamUser::Rule { kind, name, field } => format!("{kind} rule {name} ({field})"),
ParamUser::Canvas { name, input, .. } => format!("canvas {name} ({input})"),
}
@@ -780,6 +783,11 @@ impl Document {
out.push(ParamUser::Joint { joint, name: jname, input });
}
}
for c in &self.components {
if c.metadata.bom_quantity().is_some_and(|e| expr::references(e).iter().any(|r| r == name)) {
out.push(ParamUser::Quantity { component: c.id, name: c.name.clone() });
}
}
let uses = |e: &str| expr::references(e).iter().any(|r| r == name);
for r in &self.sheet.rules {
for (field, _, _) in r.fields().into_iter().filter(|(_, e, _)| uses(e)) {
@@ -946,6 +954,13 @@ impl Document {
n += 1;
}
}
for c in &mut self.components {
let Some(e) = c.metadata.bom_quantity() else { continue };
let r = expr::rename_reference(e, old, new);
if r != e && c.metadata.set(crate::metadata::Owner::Component, crate::metadata::BOM_QUANTITY, r.into()).is_ok() {
n += 1;
}
}
// Sheet metal and plastic rules and canvas planes hold expressions too (#4420).
let rules = self.sheet.rules.iter_mut().flat_map(|r| r.fields_mut()).chain(self.plastic.rules.iter_mut().flat_map(|r| r.fields_mut()));
for (_, e) in rules {
+38
View File
@@ -178,6 +178,8 @@ fn proper_rigid(m: &crate::Mat) -> bool {
}
#[cfg(test)]
mod tests {
use solidcraft_geom::{Plane, Vec2};
use super::*;
#[test]
@@ -246,6 +248,32 @@ mod tests {
assert!(Arc::ptr_eq(&mesh, &nested.mesh()));
}
/// A block 100 x 50 x 20 mm seen on a view's plane measures as worked by hand: its size along
/// each principal pair of axes, and from an isometric corner (150/sqrt(2) wide, 190/sqrt(6)
/// high; the box's extremes are its corners, so the mesh gives them exactly). A body meshed
/// already is measured from its own mesh.
#[test]
fn a_bodys_bounds_on_a_plane_are_its_mesh_seen_on_it() {
let block = ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(100., 50., 20.)).unwrap(), 1);
let plane = |x: Vec3, y: Vec3| Plane { origin: Vec3::ZERO, x, y };
let iso_x = Vec3::new(1., 1., 0.) * (1. / 2f64.sqrt());
let iso_y = Vec3::new(-1., 1., 2.) * (1. / 6f64.sqrt());
let views = [
(plane(Vec3::X, Vec3::Z), (100., 20.)),
(plane(Vec3::X, Vec3::Y), (100., 50.)),
(plane(Vec3::Y, Vec3::Z), (50., 20.)),
(plane(iso_x, iso_y), (150. / 2f64.sqrt(), 190. / 6f64.sqrt())),
];
for (basis, (w, h)) in views {
let size = block.mesh_bounds_in(&basis).size();
assert!((size.x - w).abs() < 1e-9 && (size.y - h).abs() < 1e-9, "{basis:?}: {size:?}");
}
assert!(block.cached_mesh().is_some(), "an unplaced body measures its own mesh");
let moved = Plane { origin: Vec3::new(30., 0., 5.), ..plane(Vec3::X, Vec3::Z) };
let b = block.mesh_bounds_in(&moved);
assert!(b.min.dist(Vec2::new(-30., -5.)) < 1e-9 && b.max.dist(Vec2::new(70., 15.)) < 1e-9, "{b:?}");
}
/// A placement hands out its definition's own meshes and its move without making the moved
/// copy: every placement of a part gives the same mesh, and that mesh moved is the
/// placement's own.
@@ -269,13 +297,23 @@ mod tests {
let (mesh, m) = p.mesh_in_place();
assert!(Arc::ptr_eq(&mesh, &own), "the definition's mesh, not a copy");
let bounds = p.mesh_bounds();
let tilted = Plane { origin: Vec3::new(1., 2., 3.), x: Vec3::new(0.6, 0.8, 0.), y: Vec3::Z };
let seen = p.mesh_bounds_in(&tilted);
assert!(p.cached_mesh().is_none(), "no moved copy is made");
let (plan_mesh, pm) = p.plan_mesh_in_place_at(q).unwrap();
assert!(Arc::ptr_eq(&plan_mesh, &source.plan_mesh_at(q).unwrap()));
assert_eq!(pm, m);
let moved = p.mesh();
assert!(p.cached_mesh().is_some());
let cached = p.mesh_bounds_in(&tilted);
assert!(cached.min.dist(seen.min) < 1e-9 && cached.max.dist(seen.max) < 1e-9, "a meshed placement measures its own mesh the same");
let exact = moved.bounds();
assert!(bounds.min.dist(exact.min) < 1e-9 && bounds.max.dist(exact.max) < 1e-9, "{bounds:?} vs {exact:?}");
let on_plane = moved.positions.iter().fold(solidcraft_geom::Aabb2::EMPTY, |mut b, q| {
b.add(tilted.to_local(*q));
b
});
assert!(seen.min.dist(on_plane.min) < 1e-9 && seen.max.dist(on_plane.max) < 1e-9, "{seen:?} vs {on_plane:?}");
for (a, b) in mesh.positions.iter().zip(&moved.positions) {
assert!(crate::apply_point(&m, *a).dist(*b) < 1e-9);
}
+2
View File
@@ -116,6 +116,8 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
components,
occurrences: _,
assembly: _,
// Drawings draw the model; they feed none of it.
drawings: _,
sheet,
plastic,
parts,
+343 -279
View File
@@ -1,56 +1,108 @@
//! Part properties and the bill of materials. `component.metadata` and
//! `component.metadata.set` read and change a component definition's part number, description,
//! revision and free properties (shared by every occurrence, saved with the component; the keys
//! and their checks are `solidcraft_doc::metadata`'s `KEYS`, the rows a component takes);
//! `assembly.bom` counts the placed components of the design, or of one component, and lists
//! them as rows, optionally written to a CSV or JSON file. With `group_children`, a part with a
//! part number keeps the parts placed inside it (a bolt's washers and nut) in its own row.
//! and their checks are `solidcraft_doc::metadata`'s `KEYS` and `PART_ONLY`, the rows a component
//! takes); `occurrence.bom_type` lets one occurrence count as another bill of materials type.
//!
//! A component's bill of materials type (`bom_type`, a part property) decides how it counts: a
//! phantom has no row and what it holds counts in its parent; a reference counts nowhere, and
//! nothing inside it does; a purchased or inseparable component is one row, and of what an
//! inseparable one holds only its purchased parts reach the parts-only list.
use std::collections::{BTreeMap, HashSet};
//! `assembly.bom` presents the rows of a bill of materials view of the design, or of one
//! component, as JSON, optionally written to a CSV or JSON file. `assembly.bom.renumber` and
//! `assembly.bom.item.set` number a view, which saves its item numbers; [`record_item_numbers`]
//! saves a numbered view's new rows' numbers after each change. The rows and every rule behind
//! them (bill of materials types, folding, materials, mass, item numbers) are
//! `solidcraft_doc::bom`'s.
use serde_json::{Map, Value, json};
use solidcraft_doc::BomType;
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, MAX_KEYS, Owner, check_text, key_list};
use solidcraft_doc::bom::{self, Bom, Group, MAX_ITEM, Mass, Numbering, Order, Quantity, Request, Row, Tree, Uncounted, View};
use solidcraft_doc::metadata::{BOM_KEYS, BOM_QUANTITY, BOM_TYPE, MATERIAL, MAX_KEYS, Owner, PART_ONLY, VIRTUAL, check_text, key_list};
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::schema::bom::{OccurrenceBomTypeParams, OccurrenceBomTypeResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const OCCURRENCE_BOM_TYPE_CONTRACT: TypedContract<OccurrenceBomTypeParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.metadata", "Part Properties", read).icon("settings").noundo().doc_only().params(
"component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \
bom_type (components only), metadata (as saved, other tools' keys too), properties: {name: text}",
bom_type, material (its static material or null), bom_quantity (or null) and virtual (components only), metadata (as saved, other tools' keys too), \
properties: {name: text}",
),
CommandSpec::new("component.metadata.set", "Change Part Properties", set).icon("settings").doc_only().params(
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
removes); bom_type?: normal|phantom|reference|purchased|inseparable (empty or null: normal); values?: {key: value} \
(SolidCraft's keys, as document.metadata's properties lists them, bom_type, or a reverse-DNS key holding an object); \
properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, \
metadata, properties, changed: [keys]",
removes); bom_type?: normal|phantom|reference|purchased|inseparable (empty or null: normal); material?: text, a static \
material: what its bill of materials rows say it is made of, in place of its bodies' materials (it doesn't change the \
mass; empty or null clears it); bom_quantity?: an expression, what one placement means in a bill of materials: a \
number of pieces (\"4\") or a length (\"2.5 m\", \"2 * span + 50 mm\"), evaluated with the design's parameters, above \
0 (empty or null removes it: one piece); virtual?: true|false (a component with no geometry, such as grease or a \
label: it can't be the active component or hold bodies, sketches or components); values?: {key: value} (SolidCraft's \
keys, as document.metadata's properties lists them, bom_type, material, bom_quantity, virtual, or a reverse-DNS key \
holding an object); properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, metadata, \
properties, changed: [keys]",
),
CommandSpec::new("occurrence.bom_type", "Bill of Materials Type", crate::checked!("occurrence.bom_type", occurrence_type))
.icon("bom")
.doc_only()
.contract(&OCCURRENCE_BOM_TYPE_CONTRACT)
.output(output_schema_for::<OccurrenceBomTypeResult>)
.params(
"occurrence: id or name; path?: [occurrence ids] (checked when given); bom_type: normal|phantom|reference|purchased|inseparable \
(this occurrence counts as that type in every bill of materials, in place of its component's; an occurrence inside a \
subassembly belongs to its definition, so the type holds wherever the subassembly is placed) | same_as_component or null \
(as its component again). One undo step → occurrence, bom_type (its own, or same_as_component), effective (the type it \
counts as), changed",
),
CommandSpec::new("assembly.bom", "Bill of Materials", bom).at("SOLID", "INSPECT").icon("bom").noundo().params(
"component?: id or name (default the design); levels?: flat (default: every part at any depth, counted through its \
subassemblies) | top (the component's direct children, subassemblies as one row each); group_by?: [\"part_number\"] \
subassemblies) | top (the component's direct children, subassemblies as one row each) | all (as top, each subassembly's \
own rows listed after it, to any depth: items 1.1, 1.1.1, as in the CSV); show_purchased_contents?: true (in all, a \
purchased part's contents listed under it too); group_by?: [\"part_number\"] \
(default; components without a part number get a row per definition) | [\"definition\"]; group_children?: true (a component \
with a part number is one row, the parts placed inside it listed under it as children: [{item, part_number, description, \
revision, each, count, definition}], not as rows of their own); path?: write a .csv or .json file (children as items 1.1, \
1.2…); format?: csv|json (default from the path). Each component's bom_type counts: phantom has no row (what it holds \
counts here), reference counts nowhere, purchased and inseparable are one row (in flat, an inseparable one's purchased \
parts are listed too) → rows: [{item, part_number, description, revision, bom_type, count, definition, components, \
properties, children?}] sorted by part number, total_occurrences (children included)",
revision, each, count, definition, material, mass_g?}], not as rows of their own); mass?: true (measures each part: \
mass_g, what one of the row's parts weighs in grams with what is placed inside it, except what the list shows as rows of \
their own beside it (in parts only a part's contents are rows of their own, but not a folded part's or a purchased one's, \
and of an inseparable one's only its purchased parts are), so the rows add up to the assembly; a reference placement \
adds nothing; null when nothing in it has a body, a body can't be measured or the row's definitions differ); path?: write \
a .csv (columns item, part_number, description, revision, count, definition, material, quantity) or .json file (children as \
items 1.1, 1.2…); format?: csv|json (default from the path). Each placement counts as \
its occurrence's bom_type, else its component's: phantom has no row (what it holds counts here; its own bodies, \
if it has any, are a row of their own), reference counts nowhere, purchased and inseparable are one row (in flat, an inseparable one's purchased parts are listed too); placements \
of one component that count as different types are different rows → rows: [{item, part_number, description, revision, \
bom_type, count, definition, material (the physical materials of the definitions' own bodies, \"; \" between them; a \
row that weighs what is placed inside it shows the one material all of that shares, else \"\"), mass_g?, quantity? \
(when its definitions have a bom_quantity: what its placements add up to, a number with quantity_unit pieces or mm, a \
placement without one counting 1 piece; null with quantity_unknown mixed (pieces and a length) or invalid (a quantity \
that no longer evaluates)), components, \
properties, children?, static? ([\"material\"]: a value typed in for the component, shown in place of the worked-out \
one)}] sorted by part number, total_occurrences (children included), total_mass_g (with mass: \
what the assembly weighs, measured from the design rather than added up from rows; null when unknown), numbered \
(the view's item numbers are saved: assembly.bom.renumber). In a numbered view each level is listed by item number \
and a row says provisional (its number not saved yet: a row added since shows the next number at its level, saved \
by the next change), locked and duplicate when true",
),
CommandSpec::new("assembly.bom.renumber", "Renumber Items", renumber).icon("bom").params(
"component?, levels?, group_by?, group_children?, show_purchased_contents?: the view, as assembly.bom takes them; \
start?: 1; step?: 1 (whole numbers, 1 to 999999); order?: list (default: by part number, as an unnumbered view \
lists) | item (in item-number order, closing the gaps). Numbers every row from start by step, each level on its \
own (in all levels, within its parent); locked rows keep their numbers and the others skip them; rows that are \
gone are forgotten. Saves the view's numbers, with these list options: after it, a row added takes the highest \
number at its level + 1 and a row removed leaves a gap until the next renumber. One undo step → the view as \
assembly.bom gives it",
),
CommandSpec::new("assembly.bom.item.set", "Change Item Number", item_set).icon("bom").params(
"component?, levels?, group_by?, group_children?, show_purchased_contents?: the view, as assembly.bom takes them; \
row: the row's item as the view shows it now (\"3\", or \"1.2\" under row 1); item?: 1 to 999999 (a number \
another row has is allowed: both are marked duplicate); locked?: true|false (a renumber keeps a locked number). \
A view not numbered yet is numbered first, every row keeping the number it shows. One undo step → the view as \
assembly.bom gives it, with duplicates: [item numbers used twice at the row's level]",
),
];
/// Longest free property name, in bytes.
const MAX_PROPERTY_NAME_BYTES: usize = 128;
/// How deep, and over how many placements, the bill of materials walks.
const MAX_DEPTH: usize = 64;
const MAX_PLACEMENTS: usize = 100_000;
fn key(v: Option<&Value>) -> Option<String> {
match v {
@@ -81,9 +133,13 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
for k in BOM_KEYS {
out.insert(k.to_string(), json!(m.text(k)));
}
// A bill of materials type is a component's; the design has none.
// A bill of materials type, static material, quantity and virtual flag are a component's;
// the design has none.
if id != 0 {
out.insert(BOM_TYPE.into(), json!(m.bom_type().as_str()));
out.insert(MATERIAL.into(), json!(m.material()));
out.insert(BOM_QUANTITY.into(), json!(m.bom_quantity()));
out.insert(VIRTUAL.into(), json!(m.is_virtual()));
}
out.insert("metadata".into(), json!(m));
out.insert("properties".into(), properties);
@@ -124,7 +180,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
Some(Value::Object(_)) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
Some(_) => return Err(bad(cmd, "`values` must be an object: {key: value, …}")),
}
for k in BOM_KEYS.iter().chain([&BOM_TYPE]) {
for k in BOM_KEYS.iter().chain(PART_ONLY.iter()) {
if let Some(v) = p.get(*k) {
entries.push((k.to_string(), v.clone()));
}
@@ -136,7 +192,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) => return Err(bad(cmd, "`properties` must be an object: {name: text, …}")),
};
if entries.is_empty() && props.is_empty() {
return Err(bad(cmd, format!("give {}, {BOM_TYPE}, values or properties ({})", BOM_KEYS.join(", "), key_list(Owner::Component))));
return Err(bad(
cmd,
format!("give {}, {}, values or properties ({})", BOM_KEYS.join(", "), PART_ONLY.join(", "), key_list(Owner::Component)),
));
}
let mut changed: Vec<String> = Vec::new();
for (k, v) in entries {
@@ -144,6 +203,15 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
changed.push(k);
}
}
if changed.iter().any(|k| k == BOM_QUANTITY)
&& let Some(text) = metadata.bom_quantity()
{
bom::amount(&s.doc, text).map_err(|e| bad(cmd, format!("`{BOM_QUANTITY}`: {e}")))?;
}
// What a virtual component may not hold is `Session::execute`'s check, for every command.
if metadata.is_virtual() && s.active_component == id {
return Err(bad(cmd, "the active component can't be virtual: activate another one first"));
}
for (name, value) in props {
let was = properties.get(name).cloned();
match property(name, value, cmd)? {
@@ -172,219 +240,149 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"component": id, "metadata": c.map(|c| &c.metadata), "properties": c.map(|c| &c.properties), "changed": changed}))
}
#[derive(Clone, Copy, PartialEq)]
enum Levels {
Flat,
Top,
/// The word that clears an occurrence's own bill of materials type.
pub(crate) const SAME_AS_COMPONENT: &str = "same_as_component";
/// `occurrence.bom_type`'s `bom_type`: a type, or `None` for the component's (`same_as_component`
/// or null); the handler's message for anything else, or for none given.
pub(crate) fn occurrence_bom_type(v: Option<&Value>) -> std::result::Result<Option<BomType>, String> {
let words = || format!("{}, or {SAME_AS_COMPONENT}", BomType::ALL.map(BomType::as_str).join(", "));
match v {
Some(Value::Null) => Ok(None),
Some(Value::String(w)) if w == SAME_AS_COMPONENT => Ok(None),
Some(Value::String(w)) => BomType::parse(w).map(Some).ok_or_else(|| format!("`bom_type` is one of {}", words())),
_ => Err(format!("give `bom_type`: {}", words())),
}
}
#[derive(Clone, Copy, PartialEq)]
enum Group {
PartNumber,
Definition,
}
/// One row being gathered.
struct Row {
part_number: String,
description: String,
revision: String,
bom_type: BomType,
count: u64,
components: Vec<u64>,
names: Vec<String>,
properties: BTreeMap<String, String>,
/// With `group_children`: what is placed inside one of this row's parts, and how often.
kit: BTreeMap<u64, u64>,
}
/// What the bill of materials lists and how.
#[derive(Clone, Copy)]
struct Options {
levels: Levels,
group: Group,
/// A part with a part number keeps its sub-parts in its row.
fold: bool,
}
fn options(p: &Value, cmd: &str) -> Result<Options> {
let levels = match p.get("levels") {
None | Some(Value::Null) => Levels::Flat,
Some(Value::String(l)) if l == "flat" => Levels::Flat,
Some(Value::String(l)) if l == "top" => Levels::Top,
Some(_) => return Err(bad(cmd, "`levels` is flat or top")),
fn occurrence_type(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "occurrence.bom_type";
let id = super::component::occurrence(s, p, cmd)?;
if p.get("path").is_some() {
super::component::occurrence_path(s, p, id, cmd)?;
}
let want = occurrence_bom_type(p.get("bom_type")).map_err(|e| bad(cmd, e))?;
let changed = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?.bom_type != want;
// Only a real change touches the design (an undo step, unsaved changes).
if changed && let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
o.bom_type = want;
}
let effective = s.doc.occurrences.iter().zip(s.doc.occurrence_bom_types()).find(|(o, _)| o.id == id).map(|(_, t)| t).unwrap_or_default();
let reply = OccurrenceBomTypeResult {
occurrence: id,
bom_type: want.map_or(SAME_AS_COMPONENT, BomType::as_str).to_string(),
effective: effective.as_str().to_string(),
changed,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// What `assembly.bom`'s parameters ask for: the design's bill of materials unless `component`
/// names one.
fn request(s: &Session, p: &Value, cmd: &str) -> Result<Request> {
let source = if p.get("component").is_some_and(|v| !v.is_null()) { component_id(s, p, cmd)? } else { 0 };
let views = || format!("`levels` is {}", View::ALL.map(View::as_str).join(" or "));
let view = match p.get("levels") {
None | Some(Value::Null) => View::default(),
Some(Value::String(l)) => View::parse(l).ok_or_else(|| bad(cmd, views()))?,
Some(_) => return Err(bad(cmd, views())),
};
let group_by = || bad(cmd, "`group_by` is [\"part_number\"] or [\"definition\"]");
let group = match p.get("group_by") {
None | Some(Value::Null) => Group::PartNumber,
None | Some(Value::Null) => Group::default(),
Some(Value::String(g)) => group_key(g, cmd)?,
Some(Value::Array(a)) => match a.as_slice() {
[Value::String(g)] => group_key(g, cmd)?,
_ => return Err(bad(cmd, "`group_by` is [\"part_number\"] or [\"definition\"]")),
_ => return Err(group_by()),
},
Some(_) => return Err(bad(cmd, "`group_by` is [\"part_number\"] or [\"definition\"]")),
Some(_) => return Err(group_by()),
};
let fold = match p.get("group_children") {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(_) => return Err(bad(cmd, "`group_children` is true or false")),
let flag = |k: &str| match p.get(k) {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(b)) => Ok(*b),
Some(_) => Err(bad(cmd, format!("`{k}` is true or false"))),
};
Ok(Options { levels, group, fold })
}
/// The design's structure, looked up once per bill of materials.
struct Index {
/// What each component places (by its id; 0 is the root).
children: BTreeMap<u64, Vec<u64>>,
/// Components with bodies of their own.
owners: HashSet<u64>,
/// Components with a part number: with `group_children`, one row however much is inside.
numbered: HashSet<u64>,
/// Components whose bill of materials type isn't normal.
types: BTreeMap<u64, BomType>,
}
impl Index {
fn new(s: &Session) -> Index {
let mut children: BTreeMap<u64, Vec<u64>> = BTreeMap::new();
for o in &s.doc.occurrences {
children.entry(o.parent).or_default().push(o.component);
}
let numbered = s.doc.components.iter().filter(|c| c.metadata.part_number().is_some_and(|n| !n.is_empty())).map(|c| c.id).collect();
let types = s.doc.components.iter().map(|c| (c.id, c.metadata.bom_type())).filter(|(_, t)| *t != BomType::Normal).collect();
Index { children, owners: s.doc.body_owners(&s.model.state()), numbered, types }
}
fn bom_type(&self, component: u64) -> BomType {
self.types.get(&component).copied().unwrap_or_default()
}
}
/// What a walk below a component lists.
#[derive(Clone, Copy, PartialEq)]
enum Walk {
/// What the bill of materials lists.
Listed,
/// Inside an inseparable component, in parts only: just its purchased parts.
Purchased,
Ok(Request { source, view, group, fold: flag("group_children")?, mass: flag("mass")?, purchased_contents: flag("show_purchased_contents")? })
}
fn group_key(g: &str, cmd: &str) -> Result<Group> {
match g {
"part_number" => Ok(Group::PartNumber),
"definition" => Ok(Group::Definition),
o => Err(bad(cmd, format!("can't group by `{o}` (part_number or definition)"))),
Group::parse(g).ok_or_else(|| bad(cmd, format!("can't group by `{g}` (part_number or definition)")))
}
/// A mass as `assembly.bom` gives it: grams, or null when it isn't known.
fn grams(mass: Mass) -> Value {
match mass {
Mass::Grams(grams) => json!(grams),
Mass::NotAsked | Mass::Unknown(_) => Value::Null,
}
}
/// How many times each component is listed below `root`, by its bill of materials type: in
/// `Top`, the direct children; in `Flat`, the parts at any depth (a part in a subassembly placed
/// twice counts twice), not below a purchased part, nor below a numbered part when `fold`, and of
/// what an inseparable part holds only its purchased parts. A phantom's contents count where the
/// phantom is placed; a reference counts nowhere, and nothing inside it does.
fn counts(ix: &Index, root: u64, levels: Levels, fold: bool, cmd: &str) -> Result<BTreeMap<u64, u64>> {
let mut out: BTreeMap<u64, u64> = BTreeMap::new();
let mut placed = 0usize;
let mut stack: Vec<(u64, usize, Walk)> = vec![(root, 0, Walk::Listed)];
while let Some((comp, depth, walk)) = stack.pop() {
for child in ix.children.get(&comp).into_iter().flatten() {
placed += 1;
if depth >= MAX_DEPTH || placed > MAX_PLACEMENTS {
return Err(bad(cmd, format!("the assembly is deeper than {MAX_DEPTH} levels or has more than {MAX_PLACEMENTS} placements")));
}
let inside = ix.children.contains_key(child);
match (ix.bom_type(*child), walk) {
(BomType::Reference, _) => {}
(BomType::Phantom, _) => stack.push((*child, depth + 1, walk)),
(BomType::Purchased, _) => *out.entry(*child).or_default() += 1,
(_, Walk::Purchased) => {
if inside {
stack.push((*child, depth + 1, Walk::Purchased));
}
}
(BomType::Inseparable, Walk::Listed) => {
*out.entry(*child).or_default() += 1;
if levels == Levels::Flat && inside {
stack.push((*child, depth + 1, Walk::Purchased));
}
}
(BomType::Normal, Walk::Listed) => {
// Parts only: a subassembly is listed through its parts, unless it has bodies
// of its own (or, folding, a part number).
let folded = fold && ix.numbered.contains(child);
if levels == Levels::Top || !inside || folded || ix.owners.contains(child) {
*out.entry(*child).or_default() += 1;
}
if levels == Levels::Flat && inside && !folded {
stack.push((*child, depth + 1, Walk::Listed));
}
}
}
}
/// The rows as `assembly.bom` gives them. In all levels they come one after another, each followed
/// by the rows under it (items 1, 1.1, 1.1.1), as the CSV lists them: nested, 64 levels would be
/// two containers a level, deeper than JSON readers take (serde_json stops at 128). Otherwise a
/// row's children (a folded part's contents, one level) are listed in it.
fn rows_json(list: &Bom, view: View, mass: bool) -> Vec<Value> {
if view == View::AllLevels {
list.rows_deep().iter().map(|(item, row)| row_json(row, item, mass, list.numbered, false)).collect()
} else {
list.rows.iter().map(|row| row_json(row, &[row.item], mass, list.numbered, true)).collect()
}
Ok(out)
}
/// How a kit reads when rows are compared: its parts by part number (else definition), bill of
/// materials type and count, so a kit holding a bought bolt isn't the kit holding a made one.
fn kit_key(s: &Session, ix: &Index, kit: &BTreeMap<u64, u64>) -> String {
let mut parts: Vec<String> = kit
.iter()
.map(|(id, n)| {
let pn = s.doc.components.iter().find(|c| c.id == *id).and_then(|c| c.metadata.part_number()).unwrap_or_default();
let bom_type = ix.bom_type(*id).as_str();
if pn.is_empty() { format!("#{id} {bom_type}×{n}") } else { format!("{pn} {bom_type}×{n}") }
})
.collect();
parts.sort();
parts.join("\n")
}
fn rows(s: &Session, ix: &Index, counts: &BTreeMap<u64, u64>, group: Group, kits: &BTreeMap<u64, BTreeMap<u64, u64>>) -> Vec<Row> {
let mut grouped: BTreeMap<(String, String, u64, BomType), Row> = BTreeMap::new();
for (id, n) in counts {
let Some(c) = s.doc.components.iter().find(|c| c.id == *id) else { continue };
let m = &c.metadata;
let part_number = m.part_number().unwrap_or_default().to_string();
let kit = kits.get(id).cloned().unwrap_or_default();
let bom_type = ix.bom_type(*id);
// Without a part number a definition is its own row; parts with different contents
// (a bolt with or without its nut) or different bill of materials types are different rows.
let k = match group {
Group::PartNumber if !part_number.is_empty() => (part_number.clone(), kit_key(s, ix, &kit), 0, bom_type),
_ => (String::new(), String::new(), *id, bom_type),
};
let row = grouped.entry(k).or_insert_with(|| Row {
part_number: part_number.clone(),
description: String::new(),
revision: String::new(),
bom_type,
count: 0,
components: Vec::new(),
names: Vec::new(),
properties: BTreeMap::new(),
kit,
});
row.count += n;
row.components.push(*id);
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());
/// One row, `item` its item numbers from the top (`[2, 1]` reads 2.1), with its children in it when
/// `nested`. A row listed under another reads its item as "2.1" and gives `each`, and leaves out
/// its components and properties (the output #1888 set). Its numbering flags are given when true;
/// provisional only in a `numbered` view (in one never numbered every number is).
fn row_json(row: &Row, item: &[u32], mass: bool, numbered: bool, nested: bool) -> Value {
let mut v = json!({"part_number": row.part_number, "description": row.description, "revision": row.revision,
"bom_type": row.bom_type.as_str(), "count": row.count, "definition": row.names.join("; "), "material": row.materials.join("; ")});
if item.len() <= 1 {
v["item"] = json!(row.item);
v["components"] = json!(row.components);
v["properties"] = json!(row.properties);
} else {
v["item"] = json!(bom::item_text(item));
v["each"] = json!(row.each);
}
if mass {
v["mass_g"] = grams(row.mass);
}
match row.quantity {
Quantity::Count => {}
Quantity::Pieces(n) => (v["quantity"], v["quantity_unit"]) = (json!(n), json!("pieces")),
Quantity::Length(mm) => (v["quantity"], v["quantity_unit"]) = (json!(mm), json!("mm")),
Quantity::Unknown(why) => {
v["quantity"] = Value::Null;
v["quantity_unknown"] = json!(match why {
Uncounted::Mixed => "mixed",
Uncounted::Invalid => "invalid",
});
}
}
let mut out: Vec<Row> = grouped.into_values().collect();
// By part number; rows without one after them, by name.
out.sort_by(|a, b| {
(a.part_number.is_empty(), &a.part_number, &a.names, &a.components).cmp(&(b.part_number.is_empty(), &b.part_number, &b.names, &b.components))
});
out
for (flag, on) in [("provisional", numbered && row.provisional), ("locked", row.locked), ("duplicate", row.duplicate)] {
if on {
v[flag] = json!(true);
}
}
if !row.statics.is_empty() {
v["static"] = json!(row.statics.iter().map(|s| s.key()).collect::<Vec<_>>());
}
if nested && !row.children.is_empty() {
let mut under = item.to_vec();
under.push(0);
v["children"] = row
.children
.iter()
.map(|c| {
if let Some(last) = under.last_mut() {
*last = c.item;
}
row_json(c, &under, mass, numbered, true)
})
.collect();
}
v
}
/// A CSV field, always quoted.
@@ -392,19 +390,20 @@ fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('"', "\"\""))
}
/// The bill of materials as CSV: fixed columns, every field quoted.
fn csv(rows: &[Value]) -> String {
let mut out = ["item", "part_number", "description", "revision", "count", "definition"].map(quote).join(",");
/// The bill of materials as CSV: fixed columns, every field quoted, each row followed by the rows
/// listed under it. A column added later goes at the end, so the others keep their places.
fn csv(list: &Bom) -> String {
let mut out = ["item", "part_number", "description", "revision", "count", "definition", "material", "quantity"].map(quote).join(",");
out.push('\n');
let lines = rows.iter().flat_map(|r| std::iter::once(r).chain(r["children"].as_array().into_iter().flatten()));
for r in lines {
let text = |k: &str| match &r[k] {
Value::String(t) => t.clone(),
Value::Null => String::new(),
v => v.to_string(),
for (item, r) in list.rows_deep() {
let (item, count, definition, material) = (bom::item_text(&item), r.count.to_string(), r.names.join("; "), r.materials.join("; "));
let quantity = match r.quantity {
Quantity::Pieces(n) => n.to_string(),
Quantity::Length(mm) => format!("{mm} mm"),
Quantity::Count | Quantity::Unknown(_) => String::new(),
};
let fields = ["item", "part_number", "description", "revision", "count", "definition"].map(|k| quote(&text(k)));
out.push_str(&fields.join(","));
let fields: [&str; 8] = [&item, &r.part_number, &r.description, &r.revision, &count, &definition, &material, &quantity];
out.push_str(&fields.map(quote).join(","));
out.push('\n');
}
out
@@ -412,52 +411,17 @@ fn csv(rows: &[Value]) -> String {
fn bom(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "assembly.bom";
let root = if p.get("component").is_some_and(|v| !v.is_null()) { component_id(s, p, cmd)? } else { 0 };
let o = options(p, cmd)?;
let ix = Index::new(s);
let counts = counts(&ix, root, o.levels, o.fold, cmd)?;
// Folding: what one of each numbered part holds, as parts at any depth. A purchased or
// inseparable part already keeps what it holds out of the list.
let mut kits: BTreeMap<u64, BTreeMap<u64, u64>> = BTreeMap::new();
if o.fold {
for id in counts.keys().filter(|id| ix.numbered.contains(id) && ix.bom_type(**id) == BomType::Normal) {
let kit = self::counts(&ix, *id, Levels::Flat, false, cmd)?;
if !kit.is_empty() {
kits.insert(*id, kit);
}
}
}
let mut total: u64 = 0;
let rows: Vec<Value> = rows(s, &ix, &counts, o.group, &kits)
.into_iter()
.enumerate()
.map(|(i, r)| {
total += r.count;
let mut v = json!({"item": i + 1, "part_number": r.part_number, "description": r.description, "revision": r.revision,
"bom_type": r.bom_type.as_str(), "count": r.count, "definition": r.names.join("; "), "components": r.components,
"properties": r.properties});
if !r.kit.is_empty() {
let children: Vec<Value> = self::rows(s, &ix, &r.kit, Group::PartNumber, &BTreeMap::new())
.into_iter()
.enumerate()
.map(|(j, c)| {
let count = c.count.saturating_mul(r.count);
total += count;
json!({"item": format!("{}.{}", i + 1, j + 1), "part_number": c.part_number, "description": c.description,
"revision": c.revision, "bom_type": c.bom_type.as_str(), "each": c.count, "count": count,
"definition": c.names.join("; ")})
})
.collect();
v["children"] = Value::Array(children);
}
v
})
.collect();
let request = request(s, p, cmd)?;
let state = s.model.state();
let list = bom::list(&mut Tree::new(&s.doc, &state), &request).map_err(|e| bad(cmd, e.to_string()))?;
let rows = rows_json(&list, request.view, request.mass);
let mut out = Map::new();
out.insert("component".into(), json!(root));
out.insert("levels".into(), json!(if o.levels == Levels::Top { "top" } else { "flat" }));
out.insert("group_by".into(), json!([if o.group == Group::Definition { "definition" } else { "part_number" }]));
out.insert("group_children".into(), json!(o.fold));
out.insert("component".into(), json!(request.source));
out.insert("levels".into(), json!(request.view.as_str()));
out.insert("group_by".into(), json!([request.group.as_str()]));
out.insert("group_children".into(), json!(request.fold));
out.insert("show_purchased_contents".into(), json!(request.purchased_contents));
out.insert("mass".into(), json!(request.mass));
if let Some(path) = str_(p, "path") {
let format = match str_(p, "format").map(str::to_ascii_lowercase) {
Some(f) => f,
@@ -466,8 +430,14 @@ fn bom(s: &mut Session, p: &Value) -> Result<Value> {
None => return Err(bad(cmd, "`path` must end in .csv or .json, or give `format`")),
};
let text = match format.as_str() {
"csv" => csv(&rows),
"json" => serde_json::to_string_pretty(&json!({"rows": rows, "total_occurrences": total})).map_err(|e| bad(cmd, e.to_string()))?,
"csv" => csv(&list),
"json" => {
let mut file = json!({"rows": rows, "total_occurrences": list.total.occurrences});
if request.mass {
file["total_mass_g"] = grams(list.total.mass);
}
serde_json::to_string_pretty(&file).map_err(|e| bad(cmd, e.to_string()))?
}
other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))),
};
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
@@ -475,10 +445,104 @@ fn bom(s: &mut Session, p: &Value) -> Result<Value> {
out.insert("format".into(), json!(format));
}
out.insert("rows".into(), Value::Array(rows));
out.insert("total_occurrences".into(), json!(total));
out.insert("total_occurrences".into(), json!(list.total.occurrences));
if request.mass {
out.insert("total_mass_g".into(), grams(list.total.mass));
}
out.insert("numbered".into(), json!(list.numbered));
Ok(Value::Object(out))
}
/// A whole number from 1 to [`MAX_ITEM`] (`default` when not given).
fn item_number(p: &Value, k: &str, default: Option<u32>, cmd: &str) -> Result<Option<u32>> {
match p.get(k) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_u64()
.and_then(|n| u32::try_from(n).ok())
.filter(|n| (1..=MAX_ITEM).contains(n))
.map(Some)
.ok_or_else(|| bad(cmd, format!("`{k}` is a whole number from 1 to {MAX_ITEM}"))),
}
}
/// The view numbered as `how` says, its numbers saved (only a real change touches the design), and
/// the view as `assembly.bom` gives it.
fn renumbered(s: &mut Session, p: &Value, how: Numbering, cmd: &str) -> Result<(Value, Bom)> {
let request = Request { mass: false, ..request(s, p, cmd)? };
let state = s.model.state();
let numbers = bom::number(&mut Tree::new(&s.doc, &state), &request, how).map_err(|e| bad(cmd, e.to_string()))?;
if let Some(numbers) = numbers {
let saved = &mut s.doc_mut().assembly.item_numbers;
match saved.iter_mut().find(|n| n.source == numbers.source && n.view == numbers.view) {
Some(old) => *old = numbers,
None => saved.push(numbers),
}
}
let list = bom::list(&mut Tree::new(&s.doc, &state), &request).map_err(|e| bad(cmd, e.to_string()))?;
let reply = json!({"component": request.source, "levels": request.view.as_str(), "numbered": list.numbered,
"rows": rows_json(&list, request.view, false), "total_occurrences": list.total.occurrences});
Ok((reply, list))
}
fn renumber(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "assembly.bom.renumber";
let start = item_number(p, "start", Some(1), cmd)?.unwrap_or(1);
let step = item_number(p, "step", Some(1), cmd)?.unwrap_or(1);
let order = match p.get("order") {
None | Some(Value::Null) => Order::List,
Some(Value::String(o)) if o == "list" => Order::List,
Some(Value::String(o)) if o == "item" => Order::Item,
Some(_) => return Err(bad(cmd, "`order` is list or item")),
};
Ok(renumbered(s, p, Numbering::Renumber { start, step, order }, cmd)?.0)
}
fn item_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "assembly.bom.item.set";
let shown = || bad(cmd, "`row` is the row's item as the view shows it: \"3\", or \"1.2\" under row 1");
let row: Vec<u32> = match p.get("row") {
Some(Value::Number(n)) => vec![n.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(shown)?],
Some(Value::String(text)) if text.len() <= 1024 => {
text.split('.').map(|n| n.trim().parse::<u32>().map_err(|_| shown())).collect::<Result<_>>()?
}
_ => return Err(shown()),
};
let item = item_number(p, "item", None, cmd)?;
let locked = match p.get("locked") {
None | Some(Value::Null) => None,
Some(Value::Bool(b)) => Some(*b),
Some(_) => return Err(bad(cmd, "`locked` is true or false")),
};
if item.is_none() && locked.is_none() {
return Err(bad(cmd, "give `item`, `locked` or both"));
}
let (mut reply, list) = renumbered(s, p, Numbering::Set { row: row.clone(), item, locked }, cmd)?;
// The rows listed with it: under the row above it (the top level for a row of its own).
let above = row.split_last().map_or(&[][..], |(_, above)| above);
let mut level: &[Row] = &list.rows;
for &i in above {
level = level.iter().find(|r| r.item == i).map_or(&[][..], |r| r.children.as_slice());
}
let mut duplicates: Vec<u32> = level.iter().filter(|r| r.duplicate).map(|r| r.item).collect();
duplicates.dedup();
reply["duplicates"] = json!(duplicates);
Ok(reply)
}
/// After a change, each numbered view's new rows get their numbers in the design ([`bom::record`]),
/// so they are saved in the change's undo step. A design with no view numbered lists nothing.
/// Whether the design changed.
pub(crate) fn record_item_numbers(s: &mut Session) -> bool {
if s.doc.assembly.item_numbers.is_empty() {
return false;
}
let state = s.model.state();
let Some(numbers) = bom::record(&mut Tree::new(&s.doc, &state)) else { return false };
s.doc_mut().assembly.item_numbers = numbers;
true
}
#[cfg(test)]
#[path = "bom_tests.rs"]
#[allow(clippy::unwrap_used, clippy::expect_used)]
File diff suppressed because it is too large Load Diff
+71 -14
View File
@@ -6,7 +6,9 @@
use std::collections::HashMap;
use serde_json::{Value, json};
use solidcraft_doc::{Feature, FeatureKind, Mat, Occurrence};
use solidcraft_doc::bom;
use solidcraft_doc::metadata::{BOM_QUANTITY, VIRTUAL};
use solidcraft_doc::{Document, Feature, FeatureKind, Mat, Occurrence};
use solidcraft_geom::Vec3;
use super::CommandSpec;
@@ -16,7 +18,7 @@ use crate::schema::component::{
OccurrenceAlignResult, ReparentItemsParams, ReparentItemsResult,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
use crate::{EngineError, Result, Session};
const EDIT_IN_PLACE_CONTRACT: TypedContract<EditInPlaceParams> = TypedContract::new();
const FINISH_EDIT_CONTRACT: TypedContract<FinishEditParams> = TypedContract::new();
@@ -56,7 +58,13 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("component.create", "New Component", new_component)
.at("SOLID", "ASSEMBLE")
.icon("component")
.params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true); grounded?: bool (default: true for the design's first part, false otherwise)"),
.params(
"name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool \
(default true); grounded?: bool (default: true for the design's first part, false otherwise); bom_quantity?: what \
one placement means in a bill of materials (component.metadata.set's); virtual?: true, a component with no geometry \
(grease, paint, a label): listed in bills of materials with its quantity, never active (default activate false; true \
is refused), and nothing can be put in it",
),
CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies)
.icon("component")
.params("bodies: [names] → one new component per body, named after it"),
@@ -91,10 +99,10 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("component.properties", "Component Physical Properties", properties::component)
.icon("measure").noundo()
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}"),
.params("component?: id or name (default active); descendants?: bool (default true); about?: [x, y, z] (mm). Measures the first placement, or all placements for root, including pending moves; material-aware mass and center of mass in world coordinates; volume, density, mass, center of mass and inertia_g_mm2 (the inertia tensor about the body's center of mass, world axes, from its tessellation) only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total.first_moment_g_mm (mass × center of mass), inertia_g_mm2 about the total center of mass, principal_moments_g_mm2 (smallest first) and principal_axes (unit, right-handed); with about, total.inertia_about {point, inertia_g_mm2}. A placement inside it that counts as a reference (its occurrence's bom_type, else its component's) adds nothing, nor does anything inside that; the placement measured counts whatever its type"),
CommandSpec::new("occurrence.properties", "Occurrence Physical Properties", properties::occurrence)
.icon("measure").noundo()
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties"),
.params("occurrence: id or name; path?: [occurrence ids] (required if nested under multiple instances); descendants?: bool (default true); about?: [x, y, z] (mm). Physical properties of this placement in world coordinates, including pending moves; volume, density, mass, center of mass and inertia_g_mm2 only for closed, consistently oriented bodies; total.open_bodies_skipped counts open bodies excluded from volume/mass totals; total inertia as in component.properties. A placement inside it that counts as a reference (its occurrence's bom_type, else its component's) adds nothing, nor does anything inside that; this placement counts whatever its type"),
CommandSpec::new("component.rename", "Rename Component", rename).icon("rename").params("component, name"),
CommandSpec::new("component.delete", "Delete Component", delete).icon("delete").params("component: deletes it, its subcomponents, occurrences and features"),
CommandSpec::new("component.unlock", "Unlock Contents", unlock).icon("rigid_group").params(
@@ -188,7 +196,7 @@ fn component(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<u64> {
s.doc.find_component(&kk).ok_or_else(|| bad(cmd, format!("no component `{kk}`")))
}
fn occurrence(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
pub(super) fn occurrence(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
let kk = key(p.get("occurrence")).ok_or_else(|| bad(cmd, "`occurrence` must be an id or name"))?;
s.doc.occurrences.iter().find(|o| o.id.to_string() == kk || o.name == kk).map(|o| o.id).ok_or_else(|| bad(cmd, format!("no occurrence `{kk}`")))
}
@@ -208,6 +216,26 @@ fn move_vector(p: &Value, key: &str, default: Vec3, coordinate: bool, cmd: &str)
Ok(v)
}
fn set_part_property(s: &mut Session, id: u64, key: &str, value: Value) -> Result<()> {
if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == id) {
c.metadata.set(solidcraft_doc::metadata::Owner::Component, key, value)?;
}
Ok(())
}
/// Refuse a change that puts something into a virtual component (`Document::virtual_holding`),
/// naming it; one a damaged file already filled is left as it is.
pub(crate) fn refuse_filled_virtual(before: &Document, after: &Document) -> Result<()> {
let held = after.virtual_holding();
if held.is_empty() {
return Ok(());
}
let was = before.virtual_holding();
let Some(id) = held.into_iter().find(|id| !was.contains(id)) else { return Ok(()) };
let name = after.components.iter().find(|c| c.id == id).map_or("", |c| c.name.as_str());
Err(EngineError::Other(format!("`{name}` is a virtual component: it can't hold bodies, sketches or components")))
}
pub(super) fn valid_position(m: &Mat) -> bool {
let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
let (x, y, z) = (column(0), column(1), column(2));
@@ -225,10 +253,15 @@ pub(super) fn valid_position(m: &Mat) -> bool {
pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.create";
if let Some(params) = p.as_object()
&& let Some(key) = params.keys().find(|key| !matches!(key.as_str(), "name" | "parent" | "parent_path" | "activate" | "grounded"))
&& let Some(key) = params
.keys()
.find(|key| !matches!(key.as_str(), "name" | "parent" | "parent_path" | "activate" | "grounded" | "bom_quantity" | "virtual"))
{
let hint = if params.contains_key("bodies") { "; use component.move_bodies or component.from_bodies to move bodies" } else { "" };
return Err(bad(cmd, format!("unknown parameter `{key}`; accepted parameters: name, parent, parent_path, activate, grounded{hint}")));
return Err(bad(
cmd,
format!("unknown parameter `{key}`; accepted parameters: name, parent, parent_path, activate, grounded, bom_quantity, virtual{hint}"),
));
}
let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component };
let parent_path = if let Some(value) = p.get("parent_path") {
@@ -250,6 +283,11 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
if parent_path.len() >= 64 {
return Err(bad(cmd, "new component exceeds occurrence depth limit"));
}
let made_virtual = p.get("virtual").and_then(Value::as_bool) == Some(true);
let activate = p.get("activate").and_then(Value::as_bool);
if made_virtual && activate == Some(true) {
return Err(bad(cmd, "a virtual component can't be the active component"));
}
let grounded = match p.get("grounded") {
None => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "grounded must be true or false"))?),
@@ -260,7 +298,15 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
{
o.grounded = grounded;
}
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
if let Some(text) = p.get(BOM_QUANTITY).filter(|q| !q.is_null()) {
let text = text.as_str().ok_or_else(|| bad(cmd, format!("`{BOM_QUANTITY}` is an expression, as text")))?;
bom::amount(&s.doc, text).map_err(|e| bad(cmd, format!("`{BOM_QUANTITY}`: {e}")))?;
set_part_property(s, id, BOM_QUANTITY, json!(text))?;
}
if made_virtual {
set_part_property(s, id, VIRTUAL, json!(true))?;
}
if activate.unwrap_or(!made_virtual) {
let mut path = parent_path;
if let Some(o) = s.doc.occurrence_of(id) {
path.push(o.id);
@@ -303,6 +349,9 @@ fn finish_edit(_s: &mut Session, _p: &Value) -> Result<Value> {
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
let id = component(s, p, "component", "component.activate")?;
if s.doc.components.iter().any(|c| c.id == id && c.metadata.is_virtual()) {
return Err(bad("component.activate", "a virtual component can't be the active component: it holds no geometry"));
}
let path = if p.get("path").is_some() || p.get("occurrence").is_some() {
let occurrence = occurrence(s, p, "component.activate")?;
let path = occurrence_path(s, p, occurrence, "component.activate")?;
@@ -331,6 +380,9 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
return json!(o);
}
let mut v = json!({"id": o.id, "name": o.name, "parent": o.parent, "grounded": o.grounded});
if let Some(t) = o.bom_type {
v["bom_type"] = json!(t);
}
if o.transform.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-12) {
v["transform"] = json!(o.transform);
}
@@ -600,13 +652,18 @@ fn paste_new(s: &mut Session, p: &Value) -> Result<Value> {
super::component_clone::paste(s, comp, occ, transform, parent, p)
}
/// An occurrence path as a command takes it: 1 to 64 occurrence ids.
pub(crate) fn path_ids(value: &Value) -> std::result::Result<Vec<u64>, &'static str> {
value
.as_array()
.filter(|a| !a.is_empty() && a.len() <= solidcraft_doc::MAX_PLACEMENT_DEPTH)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or("`path` must list 1..64 occurrence ids")
}
pub(super) fn occurrence_path(s: &Session, p: &Value, id: u64, cmd: &str) -> Result<Vec<u64>> {
let path = if let Some(value) = p.get("path") {
value
.as_array()
.filter(|a| !a.is_empty() && a.len() <= 64)
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
.ok_or_else(|| bad(cmd, "`path` must list 1..64 occurrence ids"))?
path_ids(value).map_err(|e| bad(cmd, e))?
} else {
let o = s.doc.occurrences.iter().find(|o| o.id == id).ok_or_else(|| bad(cmd, "no occurrence"))?;
let mut path = s.doc.first_path(o.parent);
+2
View File
@@ -323,6 +323,7 @@ pub(super) fn build(
o.transform = transform;
// A copy starts free, even of a grounded part (#2804); the parts inside it keep theirs.
o.grounded = false;
o.bom_type = source_occurrence.as_ref().and_then(|o| o.bom_type);
}
if let Some(o) = &source_occurrence {
occurrences.insert(o.id, root_occurrence);
@@ -337,6 +338,7 @@ pub(super) fn build(
let id = scratch.doc_mut().add_occurrence(child, owner, pose)?;
if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == id) {
o.grounded = old.grounded;
o.bom_type = old.bom_type;
}
occurrences.insert(old.id, id);
}
@@ -27,7 +27,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
}
fn occurrence(id: u64) -> Occurrence {
Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false }
Occurrence { id, component: id, parent: 0, name: format!("Occurrence{id}"), transform: IDENTITY, grounded: false, bom_type: None }
}
fn documents() -> (Document, Document) {
+20 -39
View File
@@ -1,10 +1,9 @@
//! Physical properties of an assembly placement, with original body material assignments.
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use solidcraft_doc::{Document, Mat};
use solidcraft_doc::{BomType, Document, Mat};
use solidcraft_geom::inertia::{self, Tensor3};
use solidcraft_geom::{Aabb3, Vec3};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use crate::params::bad;
use crate::{Result, Session};
@@ -47,25 +46,7 @@ fn placed_doc(s: &Session) -> Document {
/// A property total must cover the entire assembly. The display traversal intentionally
/// truncates hostile designs, so use an explicit error instead of accepting its prefix.
pub(in crate::cmd) fn placements(doc: &Document, cmd: &str) -> Result<Vec<(u64, Vec<u64>, Mat)>> {
let mut out = vec![(0, Vec::new(), solidcraft_doc::IDENTITY)];
let mut stack = vec![(0, Vec::<u64>::new(), solidcraft_doc::IDENTITY)];
let mut children: std::collections::BTreeMap<u64, Vec<_>> = std::collections::BTreeMap::new();
for occurrence in &doc.occurrences {
children.entry(occurrence.parent).or_default().push(occurrence);
}
while let Some((component, path, transform)) = stack.pop() {
for occurrence in children.get(&component).into_iter().flatten() {
if path.len() >= 64 || out.len() >= 100_000 {
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
}
let mut child_path = path.clone();
child_path.push(occurrence.id);
let world = solidcraft_doc::mat_mul(&transform, &occurrence.transform);
out.push((occurrence.component, child_path.clone(), world));
stack.push((occurrence.component, child_path, world));
}
}
Ok(out)
doc.placements_below(0, &[], solidcraft_doc::IDENTITY).map_err(|e| bad(cmd, e.to_string()))
}
pub(super) fn component(s: &mut Session, p: &Value) -> Result<Value> {
@@ -118,22 +99,16 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
if descendants { placements(doc, cmd)? } else { vec![(0, Vec::new(), solidcraft_doc::IDENTITY)] }
} else {
let placement = s.occurrence_placement(selected).map_err(|e| bad(cmd, e.to_string()))?;
let mut out = vec![(placement.component, selected.to_vec(), placement.world)];
let mut stack = if descendants { out.clone() } else { Vec::new() };
while let Some((component, path, transform)) = stack.pop() {
for occurrence in doc.occurrences.iter().filter(|o| o.parent == component) {
if path.len() >= 64 || out.len() >= 100_000 {
return Err(bad(cmd, "assembly exceeds physical-properties traversal limits (64 levels or 100000 placements)"));
}
let mut child = path.clone();
child.push(occurrence.id);
let world = solidcraft_doc::mat_mul(&transform, &occurrence.transform);
out.push((occurrence.component, child.clone(), world));
stack.push((occurrence.component, child, world));
}
if descendants {
doc.placements_below(placement.component, selected, placement.world).map_err(|e| bad(cmd, e.to_string()))?
} else {
vec![(placement.component, selected.to_vec(), placement.world)]
}
out
};
// As in the bill of materials, a placement that counts as a reference adds nothing, nor does
// anything inside it; the one measured counts whatever its type.
let reference: HashSet<u64> =
doc.occurrences.iter().zip(doc.occurrence_bom_types()).filter(|(_, t)| *t == BomType::Reference).map(|(o, _)| o.id).collect();
// How each closed body's volume spreads about its centre of mass (#8095): one tessellation
// however often it is placed, which each placement then turns.
let components: BTreeSet<u64> = selected_placements.iter().map(|(component, _, _)| *component).collect();
@@ -149,13 +124,19 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
// Closed bodies' (mass, centre of mass, inertia about it), all in world axes.
let mut parts: Vec<(f64, Vec3, Option<Tensor3>)> = Vec::new();
for (component, path, transform) in selected_placements {
if path.iter().skip(selected.len()).any(|id| reference.contains(id)) {
continue;
}
placement_count += 1;
for (index, body) in state.bodies.iter().enumerate().filter(|(_, b)| doc.model_body_component(b) == component) {
let transform: Mat = doc.model_body_offset(body).map(|offset| solidcraft_doc::mat_mul(&transform, offset)).unwrap_or(transform);
let placed = solidcraft_kernel::transform_matrix(&body.body, transform)?;
let measured = solidcraft_kernel::measure(&placed)?;
// The volume the bill of materials weighs too, measured once for the model; the area,
// the centre, the box and the counts are the placed body's.
let body_volume = body.volume().map_or(measured.volume, |v| v * solidcraft_doc::mat_volume_scale(&transform));
let density = doc.density(&body.name);
let body_mass = measured.volume * density / 1000.0;
let body_mass = body_volume * density / 1000.0;
let closed = crate::cmd::inspect::encloses_volume(&placed);
// The mesh's spread with the exact mass, so the tensor agrees with the mass shown.
let body_inertia = spread
@@ -164,7 +145,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
.map(|g| inertia::from_gyration(&inertia::transformed(g, &linear(&transform)), body_mass))
.filter(|i| i.iter().flatten().all(|x| x.is_finite()));
if closed {
volume += measured.volume;
volume += body_volume;
mass += body_mass;
moment += measured.centroid * body_mass;
parts.push((body_mass, measured.centroid, body_inertia));
@@ -179,7 +160,7 @@ fn properties(s: &Session, doc: &Document, selected: &[u64], descendants: bool,
bodies.push(json!({"name": body.name, "component": component, "path": path,
"material": doc.body_material(&body.name),
"closed": closed, "density_g_cm3": closed.then_some(density),
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(measured.volume),
"mass_g": closed.then_some(body_mass), "volume_mm3": closed.then_some(body_volume),
"area_mm2": measured.area, "center_of_mass": closed.then_some(measured.centroid),
"inertia_g_mm2": body_inertia,
"bbox": {"min": measured.bbox.min, "max": measured.bbox.max}}));
@@ -8,8 +8,12 @@ fn close(actual: &Value, expected: f64) {
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}");
}
fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) {
box_at(s, name, 0.0)
}
/// A new component in the active one, holding a 10 mm cube with its corner at `x` on the X axis.
fn box_at(s: &mut Session, name: &str, x: f64) -> (u64, u64, String) {
let c = run(s, "component.create", json!({"name": name, "grounded": false}));
run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
let body = s.model.state().bodies.last().unwrap().name.clone();
(c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body)
}
@@ -76,6 +80,80 @@ fn nested_instances_require_a_path_and_can_exclude_descendants() {
assert!(r["total"]["bbox"].is_null());
}
/// Study 8.5: a placement that counts as a reference (a fixture shown for context) adds nothing to
/// what is measured, nor does anything inside it, as in the bill of materials; the placement asked
/// for counts whatever its type. 10 mm cubes along X: a steel block (7.85 g at 0 mm) and a fixture
/// (1.29 g, the default density, at 20 mm) holding a pin (1.29 g at 40 mm).
#[test]
fn a_reference_placement_below_the_one_measured_adds_nothing() {
let mut s = Session::default();
let (_, _, block) = box_at(&mut s, "Block", 0.0);
run(&mut s, "material.assign", json!({"bodies": [block], "material": "Steel"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let (fixture, fixture_occurrence, _) = box_at(&mut s, "Fixture", 20.0);
let (pin, pin_occurrence, _) = box_at(&mut s, "Pin", 40.0);
run(&mut s, "component.activate", json!({"component": "root"}));
let root = |s: &mut Session| run(s, "component.properties", json!({"component": "root"}));
let counted = |r: &Value| (r["body_count"].as_u64().unwrap(), r["placement_count"].as_u64().unwrap());
let fixture_type = |s: &mut Session, bom_type: &str| run(s, "component.metadata.set", json!({"component": fixture, "bom_type": bom_type}));
let occurrence_type = |s: &mut Session, occurrence: u64, bom_type: Value| {
run(s, "occurrence.bom_type", json!({"occurrence": occurrence, "bom_type": bom_type}));
};
let all = root(&mut s);
assert_eq!(counted(&all), (3, 4), "{all}");
close(&all["total"]["mass_g"], 7.85 + 1.29 + 1.29);
close(&all["total"]["center_of_mass"][0], (5.0 * 7.85 + 25.0 * 1.29 + 45.0 * 1.29) / 10.43);
// The fixture kept for reference: only the block counts.
fixture_type(&mut s, "reference");
let block_only = root(&mut s);
assert_eq!(counted(&block_only), (1, 2), "{block_only}");
assert_eq!(block_only["bodies"][0]["name"], block.as_str());
close(&block_only["total"]["mass_g"], 7.85);
close(&block_only["total"]["volume_mm3"], 1000.0);
close(&block_only["total"]["area_mm2"], 600.0);
close(&block_only["total"]["center_of_mass"][0], 5.0);
close(&block_only["total"]["bbox"]["max"][0], 10.0);
// Asked for, the fixture counts with the pin it holds, and the pin counts too.
let by_component = run(&mut s, "component.properties", json!({"component": fixture}));
let by_occurrence = run(&mut s, "occurrence.properties", json!({"occurrence": fixture_occurrence}));
for r in [by_component, by_occurrence] {
assert_eq!(counted(&r), (2, 2), "{r}");
close(&r["total"]["mass_g"], 2.58);
close(&r["total"]["center_of_mass"][0], 35.0);
}
let pin_by_component = run(&mut s, "component.properties", json!({"component": pin}));
let pin_by_occurrence = run(&mut s, "occurrence.properties", json!({"occurrence": pin_occurrence}));
for r in [pin_by_component, pin_by_occurrence] {
assert_eq!(counted(&r), (1, 1), "{r}");
close(&r["total"]["mass_g"], 1.29);
}
// The occurrence's own type counts, not its component's.
occurrence_type(&mut s, fixture_occurrence, json!("normal"));
assert_eq!(root(&mut s), all);
fixture_type(&mut s, "normal");
occurrence_type(&mut s, fixture_occurrence, json!("reference"));
assert_eq!(root(&mut s), block_only);
// A pin kept for reference inside the fixture: the fixture's own total is its own body.
occurrence_type(&mut s, fixture_occurrence, Value::Null);
occurrence_type(&mut s, pin_occurrence, json!("reference"));
let r = run(&mut s, "component.properties", json!({"component": fixture}));
assert_eq!(counted(&r), (1, 1), "{r}");
close(&r["total"]["mass_g"], 1.29);
let r = root(&mut s);
assert_eq!(counted(&r), (2, 3), "{r}");
close(&r["total"]["mass_g"], 7.85 + 1.29);
// The same pin placed again outside the fixture counts there.
run(&mut s, "occurrence.copy", json!({"component": pin, "parent": 0, "translate": [20, 0, 0]}));
let r = root(&mut s);
assert_eq!(counted(&r), (3, 4), "{r}");
close(&r["total"]["mass_g"], 7.85 + 1.29 + 1.29);
close(&r["total"]["bbox"]["max"][0], 70.0);
}
#[test]
fn body_reassignment_offsets_preserve_world_properties() {
let mut s = Session::default();
@@ -221,7 +299,7 @@ fn compact_exponentially_instanced_assembly_refuses_partial_totals() {
}
for command in ["component.properties", "occurrence.properties"] {
let error = s.execute(command, &json!({"component": 0, "occurrence": last_occurrence})).unwrap_err();
assert!(error.to_string().contains("traversal limits"), "{error}");
assert!(error.to_string().contains("more than 100000 placements"), "{error}");
}
}
@@ -233,7 +311,7 @@ fn excessively_nested_assembly_refuses_partial_totals() {
s.active_component = s.doc_mut().add_component(None, parent).unwrap();
}
let error = s.execute("component.properties", &json!({"component": 0})).unwrap_err();
assert!(error.to_string().contains("traversal limits"), "{error}");
assert!(error.to_string().contains("deeper than 64 levels"), "{error}");
}
/// #532: a product BOM needs materials outside the built-in library (walnut, polypropylene,
+348 -7
View File
@@ -1,19 +1,22 @@
//! Delete for everything that can be selected, in one undo step: sketch entities (while
//! sketching), timeline features and sketches (with the features that depend on them),
//! construction planes and axes, bodies (a Remove feature from here on in the timeline), occurrences and
//! components, canvases, point clouds and joints. `dry_run` reports what would go and what would fail without
//! changing anything, so the UI can ask first.
//! components, canvases, point clouds and joints; or, with a drawing open, its views, dimensions and
//! balloons. `dry_run` reports what would go and what would fail without changing anything, so the UI
//! can ask first.
use std::collections::BTreeSet;
use serde_json::{Value, json};
use solidcraft_doc::FeatureKind;
use solidcraft_doc::{FeatureKind, ids};
use solidcraft_geom::Vec3;
use super::{CommandSpec, sketch_store};
use crate::params::{bad, bool_};
use crate::{EngineError, Result, Sel, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", delete).icon("delete").params(
"items?: [selection items]; components?, occurrences?, canvases?, point_clouds?, joints?: [ids or names]; dry_run?: bool → deleted, deleted_joints, would_fail, skipped (one undo step)",
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", delete).open_part().icon("delete").params(
"items?: [selection items]; components?, occurrences?, canvases?, point_clouds?, joints?: [ids or names]; dry_run?: bool → deleted, deleted_joints, would_fail, skipped (one undo step). With a drawing open, items are its views, dimensions and balloons ({type: drawing, drawing, object}); each view takes its dimensions and balloons with it; a view with views projected from it is refused, naming them, unless they are in items or contents: delete takes them too → views, dimensions, balloons: [the ids that went]",
)];
/// Run another command's body inside this one (so the whole delete is one undo step).
@@ -43,6 +46,59 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
Ok(r)
}
/// Deletes the views, dimensions and balloons `items` name in the drawing open, as one change: the
/// record's removal ([`solidcraft_doc::drawing::Drawing::remove_objects`]), with no model evaluated.
fn delete_in_drawing(s: &mut Session, p: &Value, items: &[Sel]) -> Result<Value> {
let cmd = "selection.delete";
let model = || bad(cmd, "a drawing is open: close it to change the model (drawing.close)");
if ["components", "occurrences", "canvases", "point_clouds", "joints"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) {
return Err(model());
}
let with_projected = super::drawing_sheet::contents_delete(cmd, p, "a view goes with the views projected from it")?;
let open = s.drawing(cmd)?.clone();
let (mut views, mut dimensions, mut balloons) = (BTreeSet::new(), Vec::new(), Vec::new());
for item in items {
let Sel::Drawing { drawing, object } = item else { return Err(model()) };
if *drawing != open.id {
return Err(bad(cmd, format!("{} isn't the drawing open", ids::encode(*drawing))));
}
if open.view(*object).is_some() {
views.insert(*object);
} else if open.dimension(*object).is_some() {
dimensions.push(*object);
} else if open.balloon(*object).is_some() {
balloons.push(*object);
} else {
return Err(bad(cmd, format!("the drawing open has no view, dimension or balloon {}", ids::encode(*object))));
}
}
if views.is_empty() && dimensions.is_empty() && balloons.is_empty() {
return Err(bad(cmd, "nothing to delete"));
}
let all = open.with_projected(&views);
let projected: Vec<String> = all.difference(&views).map(|view| ids::encode(*view)).collect();
if !projected.is_empty() && !with_projected {
let names = projected.join(", ");
return Err(bad(cmd, format!("views {names} are projected from what is selected: give `\"contents\": \"delete\"` to delete them too")));
}
let mut gone = open.with_contents(all.into_iter().collect());
gone.dimensions.extend(dimensions);
gone.dimensions.sort_unstable();
gone.dimensions.dedup();
gone.balloons.extend(balloons);
gone.balloons.sort_unstable();
gone.balloons.dedup();
s.edit_drawing(cmd, |d| {
d.remove_objects(&gone);
Ok(())
})?;
let listed = |objects: &[u64]| objects.iter().map(|id| ids::encode(*id)).collect::<Vec<_>>();
let (views, dimensions, balloons) = (listed(&gone.views), listed(&gone.dimensions), listed(&gone.balloons));
Ok(
json!({ "deleted": [], "deleted_joints": [], "would_fail": [], "skipped": [], "views": views, "dimensions": dimensions, "balloons": balloons }),
)
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "selection.delete";
let items: Vec<Sel> = match p.get("items") {
@@ -52,6 +108,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
Some(_) => return Err(bad(cmd, "`items` must be a list of selection items")),
};
if s.open_drawing.is_some() || items.iter().any(|x| matches!(x, Sel::Drawing { .. })) {
return delete_in_drawing(s, p, &items);
}
if let Some(value) = p.get("joints").filter(|v| !v.is_null()) {
let Some(joints) = value.as_array() else {
return Err(bad(cmd, "`joints` must be a list of ids or names"));
@@ -138,6 +197,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
}
Sel::Profile { .. } => skip("a profile goes with its sketch".into()),
Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => skip("edit the source sketch to delete its geometry".into()),
Sel::Drawing { .. } => skip("a drawing's objects are deleted with the drawing open".into()),
}
}
features.dedup();
@@ -229,9 +289,15 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use std::sync::Arc;
use crate::Session;
use serde_json::{Value, json};
use solidcraft_doc::drawing::view::{View, ViewKind, ViewPlace};
use solidcraft_doc::drawing_view::{ViewDirection, ViewOverrides};
use solidcraft_doc::ids;
use solidcraft_geom::drawing_view::NamedView;
use crate::{Sel, Session};
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
@@ -399,6 +465,281 @@ mod tests {
}
}
/// A block of the design's own and a cube in a part, in a drawing: the block's front view at 1:2,
/// its right view lined up with it, and the right view's top view placed apart, with a dimension
/// on the block's top front edge and a balloon on the cube, both in the front view. The session,
/// the three views, the dimension, the balloon and the drawing.
fn sheet_objects() -> (Session, [u64; 3], u64, u64, u64) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 50, "height": 20, "body_name": "Block"}));
let made = run(&mut s, "component.create", json!({"name": "Part"}));
let occurrence = made["occurrence"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [200, 0, 0], "operation": "new", "body_name": "Cube"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let (mut s, views) = crate::cmd::drawing_view::tests::three_views_of(s);
let front = ids::encode(views[0]);
let id = |reply: Value| ids::decode(reply["record"]["id"].as_str().unwrap()).unwrap();
let dimension = id(run(&mut s, "drawing.dimension.add", json!({"view": front, "kind": {"length": {"edge": [50, 0, 20]}}})));
let balloon =
id(run(&mut s, "drawing.balloon.add", json!({"view": front, "edge": {"point": [205, 0, 0], "path": [occurrence]}, "at": [60, 40]})));
let drawing = s.open_drawing.unwrap();
(s, views, dimension, balloon, drawing)
}
fn item(drawing: u64, object: u64) -> Value {
json!({"type": "drawing", "drawing": ids::encode(drawing), "object": ids::encode(object)})
}
/// A view, a dimension and a balloon are selected by their ids, as a view's own id reads in every
/// reply, with no undo step and no change to the design or its export. Adding one keeps the rest.
#[test]
fn a_drawings_objects_are_selected_without_changing_anything() {
let (mut s, [front, right, top], dimension, balloon, drawing) = sheet_objects();
for view in [front, right, top] {
run(&mut s, "drawing.view.edit", json!({"view": ids::encode(view), "set": {"display_style": "visible"}}));
}
let sheet = ids::encode(s.drawing("test").unwrap().sheets[0].id);
let svg = run(&mut s, "drawing.export", json!({"sheet": sheet}))["svg"].clone();
let (doc, steps) = (s.doc.clone(), s.undo.len());
let reply = run(&mut s, "select.set", json!({"items": [item(drawing, front), item(drawing, dimension), item(drawing, balloon)]}));
assert_eq!(reply["selected"], json!(3));
assert_eq!(s.selection[0], Sel::Drawing { drawing, object: front });
run(&mut s, "select.set", json!({"items": [item(drawing, right)], "add": true}));
assert_eq!(s.selection.len(), 4);
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc) && s.undo.len() == steps);
assert_eq!(run(&mut s, "drawing.export", json!({"sheet": sheet}))["svg"], svg);
run(&mut s, "select.clear", json!({}));
assert!(s.selection.is_empty());
}
/// What isn't a view, dimension or balloon of the drawing open is refused, and the selection stays
/// as it was: an unknown id, a sheet's, another drawing's, a malformed id, too many items, and
/// any of them with no drawing open.
#[test]
fn what_isnt_on_the_drawing_open_is_not_selected() {
let (mut s, [front, ..], _, _, drawing) = sheet_objects();
let sheet = s.drawing("test").unwrap().sheets[0].id;
run(&mut s, "select.set", json!({"items": [item(drawing, front)]}));
let other = ids::decode(run(&mut s, "drawing.create", json!({}))["id"].as_str().unwrap()).unwrap();
let others = run(&mut s, "drawing.view.base", json!({"sheet": "Sheet 1", "scale": "1:2"}));
let others = ids::decode(others["view"].as_str().unwrap()).unwrap();
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
run(&mut s, "select.set", json!({"items": [item(drawing, front)]}));
let refused = |s: &mut Session, items: Value| {
let before = s.selection.clone();
assert!(s.execute("select.set", &json!({"items": items})).is_err(), "{items}");
assert_eq!(s.selection, before, "{items}");
};
refused(&mut s, json!([item(drawing, 99)]));
refused(&mut s, json!([item(drawing, 0)]));
refused(&mut s, json!([item(drawing, sheet)]));
refused(&mut s, json!([item(99, front)]));
refused(&mut s, json!([item(other, front)]));
refused(&mut s, json!([item(other, others)]));
refused(&mut s, json!([{"type": "drawing", "drawing": ids::encode(drawing), "object": front}]));
refused(&mut s, json!([{"type": "drawing", "drawing": ids::encode(drawing), "object": "x"}]));
refused(&mut s, json!([{"type": "drawing", "drawing": ids::encode(drawing)}]));
refused(&mut s, json!(vec![item(drawing, front); 10_001]));
run(&mut s, "drawing.close", json!({}));
refused(&mut s, json!([item(drawing, front)]));
}
/// A selected object that goes leaves the selection, and the rest stay: a view going with its
/// sheet, then closing the drawing takes the others. (An undo or a selection's delete clears the
/// whole selection, as they always have.)
#[test]
fn a_selected_object_that_goes_leaves_the_selection() {
let (mut s, [front, ..], dimension, _, drawing) = sheet_objects();
run(&mut s, "drawing.sheet.add", json!({"name": "Other"}));
let mut d = (**s.drawing("test").unwrap()).clone();
let id = d.allocate_id(&mut ids::IdSource::seeded(4894)).unwrap();
let kind = ViewKind::Base { source: d.sources[0].id, view: ViewDirection::Named(NamedView::Front) };
let on_other = View {
id,
sheet: d.sheets[1].id,
kind,
place: ViewPlace::At([100.0, 100.0]),
settings: ViewOverrides::default(),
reference_shown_percent: 0.0,
};
d.add_views([Arc::new(on_other)]);
s.doc_mut().put_drawing(Arc::new(d));
run(&mut s, "select.set", json!({"items": [item(drawing, front), item(drawing, dimension), item(drawing, id)]}));
run(&mut s, "drawing.sheet.delete", json!({"sheet": "Other", "contents": "delete"}));
assert_eq!(s.selection, [Sel::Drawing { drawing, object: front }, Sel::Drawing { drawing, object: dimension }]);
run(&mut s, "drawing.close", json!({}));
assert!(s.selection.is_empty());
}
fn listed(ids_of: &[u64]) -> Value {
json!(ids_of.iter().map(|id| ids::encode(*id)).collect::<Vec<_>>())
}
/// A view goes with its dimensions and balloons, as one undo step that brings all back. A dry run
/// says the same and changes nothing, and the selection is cleared only by the real delete.
#[test]
fn a_view_goes_with_what_is_in_it_after_a_dry_run() {
let (mut s, [front, right, top], dimension, balloon, drawing) = sheet_objects();
let record = s.drawing("test").unwrap().clone();
let (doc, steps) = (s.doc.clone(), s.undo.len());
run(&mut s, "select.set", json!({"items": [item(drawing, top)]}));
let p = json!({"items": [item(drawing, front)], "contents": "delete"});
let mut dry = p.clone();
dry["dry_run"] = json!(true);
let said = run(&mut s, "selection.delete", dry);
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc) && s.undo.len() == steps, "a dry run changes nothing");
assert_eq!(s.selection.len(), 1);
let reply = run(&mut s, "selection.delete", p);
assert_eq!(said, reply);
let mut views = vec![front, right, top];
views.sort_unstable();
assert_eq!(
(&reply["views"], &reply["dimensions"], &reply["balloons"]),
(&listed(&views), &listed(&[dimension]), &listed(&[balloon])),
"{reply}"
);
assert_eq!(s.undo.len(), steps + 1, "one undo step");
assert!(s.selection.is_empty());
let d = s.drawing("test").unwrap();
assert!(d.views.is_empty() && d.dimensions.is_empty() && d.balloons.is_empty());
assert_eq!(d.sheets.len(), 1, "the sheet stays");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.drawing("test").unwrap(), &record);
}
/// A view with views projected from it is refused with their names, on any sheet, unless they
/// are selected too or `contents` takes them; a view from the end of a chain goes alone, and its
/// parents stay.
#[test]
fn a_views_projected_views_go_only_when_asked() {
let (mut s, [front, right, top], dimension, _, drawing) = sheet_objects();
run(&mut s, "drawing.sheet.add", json!({"name": "Other"}));
run(&mut s, "drawing.view.move_to_sheet", json!({"view": ids::encode(top), "sheet": "Other"}));
let before = s.drawing("test").unwrap().clone();
let e = s.execute("selection.delete", &json!({"items": [item(drawing, right)]})).unwrap_err().to_string();
assert!(e.contains(&ids::encode(top)) && e.contains("\"contents\": \"delete\"") && !e.contains(&ids::encode(front)), "{e}");
assert_eq!(s.drawing("test").unwrap(), &before);
for contents in [json!("all"), json!(true), json!(null)] {
assert!(s.execute("selection.delete", &json!({"items": [item(drawing, top)], "contents": contents})).is_err(), "{contents}");
}
assert_eq!(s.drawing("test").unwrap(), &before);
// Selected with its child: nothing else is in the way.
let reply = run(&mut s, "selection.delete", json!({"items": [item(drawing, top), item(drawing, right)]}));
let mut views = vec![right, top];
views.sort_unstable();
assert_eq!(reply["views"], listed(&views));
let d = s.drawing("test").unwrap();
assert_eq!(d.views.iter().map(|v| v.id).collect::<Vec<_>>(), [front]);
assert_eq!(d.dimensions.iter().map(|x| x.id).collect::<Vec<_>>(), [dimension], "the front view keeps its dimension");
}
/// A dimension or a balloon goes alone, or with its view selected too (once in the reply), and a
/// mixed selection is one undo step. A dimension that lost its edge can be deleted like any other.
#[test]
fn a_dimension_and_a_balloon_go_alone_or_with_their_view() {
let (mut s, [front, ..], dimension, balloon, drawing) = sheet_objects();
let steps = s.undo.len();
let reply = run(&mut s, "selection.delete", json!({"items": [item(drawing, dimension)]}));
assert_eq!((&reply["views"], &reply["dimensions"]), (&json!([]), &listed(&[dimension])), "{reply}");
assert!(s.drawing("test").unwrap().dimension(dimension).is_none() && s.drawing("test").unwrap().view(front).is_some());
run(&mut s, "edit.undo", json!({}));
let all = json!({"items": [item(drawing, balloon), item(drawing, dimension), item(drawing, dimension), item(drawing, front)], "contents": "delete"});
let reply = run(&mut s, "selection.delete", all);
assert_eq!((&reply["dimensions"], &reply["balloons"]), (&listed(&[dimension]), &listed(&[balloon])), "{reply}");
assert_eq!(s.undo.len(), steps + 1);
run(&mut s, "edit.undo", json!({}));
let name = ids::encode(drawing);
run(&mut s, "drawing.close", json!({}));
let block = s.doc.features[0].id;
run(&mut s, "timeline.delete", json!({"features": [block.to_string()]}));
run(&mut s, "drawing.open", json!({"drawing": name}));
let read = run(&mut s, "drawing.dimension.inspect", json!({"dimension": ids::encode(dimension)}));
assert_ne!(read["status"], json!("ok"), "{read}");
run(&mut s, "selection.delete", json!({"items": [item(drawing, dimension)]}));
assert!(s.drawing("test").unwrap().dimension(dimension).is_none());
}
/// Chosen in any order, dimensions and balloons all go and are listed by id.
#[test]
fn what_is_chosen_goes_in_any_order() {
let (mut s, [front, ..], dimension, balloon, drawing) = sheet_objects();
let front = ids::encode(front);
let id = |reply: Value| ids::decode(reply["record"]["id"].as_str().unwrap()).unwrap();
let occurrence = s.doc.occurrences[0].id;
let next_dimension = id(run(&mut s, "drawing.dimension.add", json!({"view": front, "kind": {"length": {"edge": [50, 0, 0]}}})));
let next_balloon =
id(run(&mut s, "drawing.balloon.add", json!({"view": front, "edge": {"point": [205, 0, 10], "path": [occurrence]}, "at": [80, 40]})));
let mut dimensions = [dimension, next_dimension];
let mut balloons = [balloon, next_balloon];
dimensions.sort_unstable();
balloons.sort_unstable();
let items: Vec<Value> = dimensions.iter().chain(&balloons).rev().map(|object| item(drawing, *object)).collect();
let reply = run(&mut s, "selection.delete", json!({"items": items}));
assert_eq!((&reply["dimensions"], &reply["balloons"]), (&listed(&dimensions), &listed(&balloons)), "{reply}");
let d = s.drawing("test").unwrap();
assert!(d.dimensions.is_empty() && d.balloons.is_empty());
}
/// What isn't there, or isn't the drawing's, is refused whole with the drawing as it was: unknown
/// ids beside good ones, another drawing, the model's items and lists while a drawing is open,
/// nothing at all, and a drawing's objects with none open.
#[test]
fn hostile_deletes_in_a_drawing_are_refused_whole() {
let (mut s, [front, ..], _, _, drawing) = sheet_objects();
let sheet = s.drawing("test").unwrap().sheets[0].id;
let before = s.drawing("test").unwrap().clone();
let good = item(drawing, front);
for (p, says) in [
(json!({"items": [good, item(drawing, 99)]}), "no view, dimension or balloon"),
(json!({"items": [good, item(drawing, sheet)]}), "no view, dimension or balloon"),
(json!({"items": [good, item(99, front)]}), "isn't the drawing open"),
(json!({"items": [good, {"type": "body", "name": "Block"}]}), "a drawing is open"),
(json!({"items": [{"type": "feature", "id": 1}]}), "a drawing is open"),
(json!({"components": ["Part"]}), "a drawing is open"),
(json!({"occurrences": ["Part:1"]}), "a drawing is open"),
(json!({"joints": []}), "a drawing is open"),
(json!({}), "nothing to delete"),
(json!({"items": []}), "nothing to delete"),
(json!({"items": [{"type": "drawing", "drawing": ids::encode(drawing), "object": front}]}), "bad item"),
] {
let e = s.execute("selection.delete", &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
assert!(e.contains(says), "{p}: {e}");
assert_eq!(s.drawing("test").unwrap(), &before, "{p}");
}
run(&mut s, "drawing.close", json!({}));
let e = s.execute("selection.delete", &json!({"items": [item(drawing, front)]})).unwrap_err().to_string();
assert!(e.contains("open a drawing first"), "{e}");
}
/// Seeded selections of the drawing's objects, deleted or dry-run in turn with undos between:
/// each is refused or leaves a drawing that keeps its rules, saves and reads back as itself, and
/// holds nothing that hangs off what went.
#[test]
fn seeded_deletes_in_a_drawing_keep_its_rules() {
let pick = |seed: u64, salt: u64, n: u64| ((seed ^ salt).wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 33) % n;
let (mut s, [front, right, top], dimension, balloon, drawing) = sheet_objects();
let objects = [front, right, top, dimension, balloon, 99];
for seed in 1..=300u64 {
let items: Vec<Value> = (0..pick(seed, 1, 5)).map(|i| item(drawing, objects[pick(seed, 10 + i, 6) as usize])).collect();
let mut p = json!({"items": items});
if pick(seed, 2, 2) == 0 {
p["contents"] = json!("delete");
}
if pick(seed, 3, 4) == 0 {
p["dry_run"] = json!(true);
}
let _ = s.execute("selection.delete", &p);
let d = s.drawing("test").unwrap();
d.validate().unwrap();
assert_eq!(solidcraft_doc::drawing::Drawing::from_json(&d.to_json().unwrap()).unwrap(), **d, "seed {seed}");
assert!(d.dimensions.iter().all(|x| d.view(x.view).is_some()) && d.balloons.iter().all(|x| d.view(x.view).is_some()), "seed {seed}");
if pick(seed, 4, 3) == 0 {
let _ = s.execute("edit.undo", &json!({}));
}
}
}
fn joint_assembly() -> Session {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 30), ("C", 60)] {
+4 -3
View File
@@ -8,21 +8,22 @@ use crate::params::{bad, str_};
use crate::{EngineError, Result, Session, crash};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).icon("bug_report").noundo().params(
CommandSpec::new("diagnostics.crash_reports", "Crash Reports", list).any_part().icon("bug_report").noundo().params(
"limit?: number (default 20). Lists this computer's crash reports, newest first: {dir, reports: [{id, kind, time, message, \
command, count, version, path, replayable}]}. kind: command_panic | panic | kernel_panic_recovered | unclean_exit. \
Read one with diagnostics.crash_report; reproduce it with `solidcraft-cli crashes replay <id>`",
),
CommandSpec::new("diagnostics.crash_report", "Crash Report", read).icon("bug_report").noundo().params(
CommandSpec::new("diagnostics.crash_report", "Crash Report", read).any_part().icon("bug_report").noundo().params(
"id: report id. The whole report: message, location, backtrace, app version and commit, system, the command that \
crashed and the recent ones, notes, and design_path / replay_path when the crash can be replayed \
(`solidcraft-cli run <replay_path> --in <design_path>`)",
),
CommandSpec::new("diagnostics.crash_reports.clear", "Delete Crash Reports", clear)
.any_part()
.icon("delete")
.noundo()
.params("id?: one report (default: all). Returns {deleted}"),
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).icon("bug_report").noundo().enabled(crash_test_enabled).params(
CommandSpec::new("diagnostics.crash_test", "Crash Test", crash_test).any_part().icon("bug_report").noundo().enabled(crash_test_enabled).params(
"mode: panic | kernel | abort. Checks that crash reports work: panic = an internal error in this command (caught; \
the design is unchanged), kernel = one inside the geometry kernel (caught), abort = the app ends at once (reported \
at the next start). Only when the SOLIDCRAFT_CRASH_TEST environment variable is set",
+329
View File
@@ -0,0 +1,329 @@
//! Drawing commands (#3890, K4): make, list, open, close and delete a design's drawings, and read
//! and change the open drawing's own settings. A drawing lives in its design's file
//! ([`solidcraft_doc::Document::drawings`]); while one is open, the drawing commands change it and
//! the model's wait ([`super::Part`]). Each change of a drawing is one undo step of that drawing,
//! and evaluates nothing; making and deleting one are steps of the model, whose list of drawings
//! they change.
use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_doc::drawing::view::{MAX_VIEW_DEPTH, View, ViewKind};
use solidcraft_doc::drawing::{self, Drawing, Source, Units};
use solidcraft_doc::drawing_view::ViewDirection;
use solidcraft_doc::ids;
use solidcraft_geom::cell::Projection;
use solidcraft_geom::drawing_sheet::{DrawingBorderProfile, Orientation, PaperSize, Sheet};
use solidcraft_geom::drawing_view::ProjectedSide;
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("drawing.create", "Create Drawing", create).any_part().icon("new").doc_only().params(
"name?: the drawing's name (default: the design's); language?: the drawing's content language, a BCP 47 tag (default en); units?: \"follow_model\" (default) or one length unit such as \"mm\"; font?: the font family its text is set in, by name (default: the built-in font). Adds a drawing of this design, one ISO A3 landscape sheet with the ISO border, and opens it (one undo step of the model) → as drawing.inspect",
),
CommandSpec::new("drawing.list", "List Drawings", list)
.any_part()
.icon("list")
.noundo()
.doc_only()
.params("→ drawings: [{id, name, sheets (how many), open}], by name, then id"),
CommandSpec::new("drawing.open", "Open Drawing", open)
.any_part()
.icon("open")
.noundo()
.doc_only()
.params("drawing: id (from drawing.list) → as drawing.inspect. The drawing commands then change it; the model's wait until drawing.close"),
CommandSpec::new("drawing.close", "Close Drawing", close)
.any_part()
.icon("close")
.noundo()
.doc_only()
.params("→ closed: the drawing's id, or null when none was open"),
CommandSpec::new("drawing.delete", "Delete Drawing", delete).any_part().icon("delete").doc_only().params(
"drawing: id → deleted: its id; closed when it was open (one undo step of the model)",
),
CommandSpec::new("drawing.inspect", "Inspect Drawing", inspect).in_drawing().icon("inspect").noundo().doc_only().params(
"→ {id, name, units, projection, language, font, font_note: why its text is set in the built-in font instead (the font isn't on this computer, or can't be set on paper), or null, properties, sources: [{id}], sheets: [{id, name, size_mm, orientation, dpi, documents, excluded, views: [{id, kind: base|projected, parent?, side?, label, dimensions: [ids], balloons: [ids]}], base views by id, each followed by those projected from it}], revisions: {rows: [{row, revision, description, designer, checker, date, hidden}], the oldest first (the newest is the drawing's revision), next: the revision a new row gets}}. Read-only.",
),
CommandSpec::new("drawing.set", "Drawing Settings", set)
.in_drawing()
.icon("settings")
.doc_only()
.params("name?, language?: a BCP 47 tag, units?: \"follow_model\" or one length unit such as \"mm\", projection?: first or third (angle), which every projected view follows, font?: the font family its text is set in, by name, never a file (\"\": the built-in font). One undo step."),
CommandSpec::new("drawing.properties", "Drawing Properties", properties).in_drawing().icon("settings").noundo().doc_only().params(
"→ metadata: the drawing's properties as saved (other tools' keys too); properties: project, part_number, revision, author, organization, description and license, each its text or null. The design properties' rules (document.metadata).",
),
CommandSpec::new("drawing.properties.set", "Change Drawing Properties", properties_set).in_drawing().icon("settings").doc_only().params(
"key and value, or values: {key: value, …} (all or nothing, one undo step), as document.metadata.set takes them → metadata, changed: [keys]",
),
CommandSpec::new("drawing.properties.remove", "Remove Drawing Property", properties_remove)
.in_drawing()
.icon("delete")
.doc_only()
.params("key, or keys: [keys] → metadata, removed: [keys that were there]"),
];
/// `units` as given: `follow_model`, or a length unit the drawing's rules check.
fn units(cmd: &str, p: &Value) -> Result<Option<Units>> {
match p.get("units") {
None => Ok(None),
Some(Value::String(u)) if u == "follow_model" => Ok(Some(Units::FollowModel)),
Some(Value::String(u)) => Ok(Some(Units::Fixed { unit: u.clone() })),
Some(_) => Err(bad(cmd, "`units` is \"follow_model\" or a length unit such as \"mm\"")),
}
}
/// `projection` as given: first or third angle.
fn projection(cmd: &str, p: &Value) -> Result<Option<Projection>> {
match p.get("projection") {
None => Ok(None),
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|_| bad(cmd, "`projection` is first or third (angle)")),
}
}
/// `font` as given: a font family's name, never a file (drawings name fonts by family, so they
/// travel).
fn font<'a>(cmd: &str, p: &'a Value) -> Result<Option<&'a str>> {
let font = text(cmd, p, "font")?;
if let Some(family) = font {
solidcraft_sketch::check_family(family).map_err(|e| bad(cmd, e.to_string()))?;
}
Ok(font.map(str::trim))
}
/// An optional text parameter: absent, or a string.
fn text<'a>(cmd: &str, p: &'a Value, key: &str) -> Result<Option<&'a str>> {
match p.get(key) {
None => Ok(None),
Some(_) => str_(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be text"))),
}
}
/// The design's drawing `p.drawing` names, by id.
fn named(cmd: &str, s: &Session, p: &Value) -> Result<u64> {
let id = str_(p, "drawing").and_then(ids::decode).ok_or_else(|| bad(cmd, "`drawing` must be a drawing's id (drawing.list)"))?;
s.doc.drawing(id).map(|d| d.id).ok_or_else(|| bad(cmd, format!("the design has no drawing {}", ids::encode(id))))
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "drawing.create";
if s.doc.features.is_empty() {
return Err(bad(cmd, "the design is empty: there is nothing to draw yet"));
}
drawing::check_count(s.doc.drawings.len() + 1).map_err(|e| bad(cmd, e.to_string()))?;
let name = text(cmd, p, "name")?.map_or_else(|| s.doc.name.clone(), str::to_string);
let language = text(cmd, p, "language")?.unwrap_or("en");
// Not the id of a drawing undo or redo could bring back either.
let steps = s.undo.iter().chain(&s.redo).filter_map(|step| step.part);
let taken: BTreeSet<u64> = s.doc.drawings.iter().map(|d| d.id).chain(steps).collect();
let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape);
let fail = |e: solidcraft_doc::DocError| bad(cmd, e.to_string());
let mut d = Drawing::new(&name, language, "Sheet 1", a3, Some(DrawingBorderProfile::iso()), &taken, &mut s.ids).map_err(fail)?;
let source = d.allocate_id(&mut s.ids).map_err(fail)?;
d.sources.push(Source { id: source });
if let Some(units) = units(cmd, p)? {
d.units = units;
}
if let Some(font) = font(cmd, p)? {
d.font = font.to_string();
}
d.validate().map_err(fail)?;
s.open_drawing = Some(d.id);
s.doc_mut().put_drawing(Arc::new(d));
inspect(s, &Value::Null)
}
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
let listed: Vec<Value> = s
.doc
.drawings
.iter()
.map(|d| json!({"id": ids::encode(d.id), "name": d.name, "sheets": d.sheets.len(), "open": s.open_drawing == Some(d.id)}))
.collect();
Ok(json!({"drawings": listed}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
s.open_drawing = Some(named("drawing.open", s, p)?);
inspect(s, &Value::Null)
}
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"closed": s.open_drawing.take().map(ids::encode)}))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let id = named("drawing.delete", s, p)?;
if s.open_drawing == Some(id) {
s.open_drawing = None;
}
s.doc_mut().drawings.retain(|d| d.id != id);
Ok(json!({"deleted": ids::encode(id)}))
}
/// What view `view` is called until views have saved names: which way it looks, or where it sits of
/// the view it is projected from (`Front`, `Right of Front`, `Above Right of Front`).
fn view_label(d: &Drawing, view: &View) -> String {
let mut label = String::new();
let mut at = view;
for _ in 0..MAX_VIEW_DEPTH {
match at.kind {
ViewKind::Base { view: ViewDirection::Named(named), .. } => {
let name = super::drawing_view::snake_name(named).replace('_', " ");
let mut letters = name.chars();
label.extend(letters.next().map(|first| first.to_ascii_uppercase()));
label.push_str(letters.as_str());
break;
}
ViewKind::Base { .. } => {
label.push_str("Custom");
break;
}
ViewKind::Projected { parent, side } => {
label.push_str(match side {
ProjectedSide::Left => "Left of ",
ProjectedSide::Right => "Right of ",
ProjectedSide::Above => "Above ",
ProjectedSide::Below => "Below ",
});
let Some(next) = d.view(parent) else { break };
at = next;
}
}
}
label
}
/// Sheet `sheet`'s views as `drawing.inspect` lists them: those with no parent on it by id, each
/// followed by the views projected from it. `dimensions` and `balloons` hold each view's, by its id.
fn sheet_views(d: &Drawing, sheet: u64, dimensions: &BTreeMap<u64, Vec<String>>, balloons: &BTreeMap<u64, Vec<String>>) -> Vec<Value> {
let on: BTreeSet<u64> = d.views.iter().filter(|v| v.sheet == sheet).map(|v| v.id).collect();
let mut first = Vec::new();
let mut projected: BTreeMap<u64, Vec<&View>> = BTreeMap::new();
for view in d.views.iter().filter(|v| on.contains(&v.id)) {
match view.kind {
ViewKind::Projected { parent, .. } if on.contains(&parent) => projected.entry(parent).or_default().push(view),
_ => first.push(&**view),
}
}
let mut stack: Vec<&View> = first.into_iter().rev().collect();
let mut listed = Vec::with_capacity(on.len());
while let Some(view) = stack.pop() {
let mut entry = json!({
"id": ids::encode(view.id),
"kind": if matches!(view.kind, ViewKind::Base { .. }) { "base" } else { "projected" },
"label": view_label(d, view),
"dimensions": dimensions.get(&view.id).map_or(&[][..], Vec::as_slice),
"balloons": balloons.get(&view.id).map_or(&[][..], Vec::as_slice),
});
if let ViewKind::Projected { parent, side } = view.kind {
entry["parent"] = json!(ids::encode(parent));
entry["side"] = json!(side);
}
listed.push(entry);
if let Some(children) = projected.get(&view.id) {
stack.extend(children.iter().rev());
}
}
listed
}
/// The drawing open, as `drawing.inspect` answers, which making and opening one answer too.
fn inspect(s: &mut Session, _p: &Value) -> Result<Value> {
let cmd = "drawing.inspect";
let d = s.drawing(cmd)?;
// One pass over each list for the whole drawing, however many sheets and views it has.
let mut dimensions: BTreeMap<u64, Vec<String>> = BTreeMap::new();
for dimension in &d.dimensions {
dimensions.entry(dimension.view).or_default().push(ids::encode(dimension.id));
}
let mut balloons: BTreeMap<u64, Vec<String>> = BTreeMap::new();
for balloon in &d.balloons {
balloons.entry(balloon.view).or_default().push(ids::encode(balloon.id));
}
let mut sheets = Vec::with_capacity(d.sheets.len());
for sheet in &d.sheets {
let size = sheet.sheet.size_mm().map_err(|e| bad(cmd, e.to_string()))?;
sheets.push(json!({
"id": ids::encode(sheet.id),
"name": sheet.name,
"size_mm": [size.x, size.y],
"orientation": sheet.sheet.orientation,
"dpi": sheet.dpi,
"documents": sheet.documents.map(|c| json!({"source": ids::encode(c.source), "component": c.component})),
"excluded": sheet.excluded,
"views": sheet_views(d, sheet.id, &dimensions, &balloons),
}));
}
Ok(json!({
"id": ids::encode(d.id),
"name": d.name,
"units": d.units,
"projection": d.projection,
"language": d.language,
"font": d.font,
"font_note": solidcraft_sketch::paper_font_note(&solidcraft_sketch::font_dirs(), &d.font),
"properties": d.properties,
"sources": d.sources.iter().map(|source| json!({"id": ids::encode(source.id)})).collect::<Vec<_>>(),
"sheets": sheets,
"revisions": {"rows": super::drawing_revision::rows(&d.revisions), "next": d.revisions.next_revision(None)},
}))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "drawing.set";
s.edit_drawing(cmd, |d| {
if let Some(name) = text(cmd, p, "name")? {
d.name = name.to_string();
}
if let Some(language) = text(cmd, p, "language")? {
d.language = language.to_string();
}
if let Some(units) = units(cmd, p)? {
d.units = units;
}
if let Some(projection) = projection(cmd, p)? {
d.projection = projection;
}
if let Some(font) = font(cmd, p)? {
d.font = font.to_string();
}
Ok(())
})?;
inspect(s, &Value::Null)
}
fn properties(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(super::metadata::reading(&s.drawing("drawing.properties")?.properties))
}
/// The open drawing with the properties `next`, kept when they changed: one undo step.
fn keep_properties(s: &mut Session, cmd: &str, next: solidcraft_doc::Metadata) -> Result<Value> {
s.edit_drawing(cmd, |d| {
d.properties = Arc::new(next);
Ok(())
})?;
Ok(json!(s.drawing(cmd)?.properties))
}
fn properties_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "drawing.properties.set";
let (next, changed) = super::metadata::setting(cmd, &s.drawing(cmd)?.properties, p)?;
let metadata = keep_properties(s, cmd, next)?;
Ok(json!({"metadata": metadata, "changed": changed}))
}
fn properties_remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "drawing.properties.remove";
let (next, removed) = super::metadata::removing(cmd, &s.drawing(cmd)?.properties, p)?;
let metadata = keep_properties(s, cmd, next)?;
Ok(json!({"metadata": metadata, "removed": removed}))
}
#[cfg(test)]
#[path = "drawing_tests.rs"]
mod tests;
+125
View File
@@ -0,0 +1,125 @@
//! `drawing.balloon.add`, `.edit` and `.inspect` (W26, #3874): a balloon on a saved view of the
//! open drawing, pointing at a placed part by an edge of it, and where it stands with the model
//! now. The edge is picked as a dimension's is and saved by name
//! ([`solidcraft_doc::Document::edge_reference`]); its path is the part the balloon numbers.
//! Adding and editing are one undo step of the drawing each, and none of the commands evaluates
//! the model.
use std::sync::Arc;
use serde_json::Value;
use solidcraft_doc::drawing::balloon::Balloon;
use solidcraft_doc::ids;
use super::CommandSpec;
use super::drawing_dimension::status_words;
use crate::params::bad;
use crate::schema::drawing_balloon::{AddParams, BalloonResult, EditParams, EditResult, InspectParams};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const ADD: &str = "drawing.balloon.add";
const ADD_CONTRACT: TypedContract<AddParams> = TypedContract::new();
const EDIT: &str = "drawing.balloon.edit";
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
const INSPECT: &str = "drawing.balloon.inspect";
const INSPECT_CONTRACT: TypedContract<InspectParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new(ADD, "Add Balloon", crate::checked!("drawing.balloon.add", add))
.icon("new")
.in_drawing()
.doc_only()
.contract(&ADD_CONTRACT)
.output(output_schema_for::<BalloonResult>)
.params(
"view: a saved view's id; edge: the edge its arrow points at, a point on it [x, y, z] in the design's frame, {point} or \
{body, index}, with path: the occurrences its body is placed through, from the top (the part the balloon numbers; at \
least one); at: [x, y], the balloon's centre on paper in mm from the view's centre → what drawing.balloon.inspect \
answers for it. Saved by the edge's name, never the point picked. One undo step; no model is evaluated.",
),
CommandSpec::new(EDIT, "Edit Balloon", crate::checked!("drawing.balloon.edit", edit))
.icon("edit")
.in_drawing()
.doc_only()
.contract(&EDIT_CONTRACT)
.output(output_schema_for::<EditResult>)
.params(
"balloon: its id; set: {at?: [x, y]} → {balloon, changed: [fields]}. What it points at stays: to number another part, add \
a new one. One undo step, none when nothing changes; no model is evaluated.",
),
CommandSpec::new(INSPECT, "Inspect Balloon", crate::checked!("drawing.balloon.inspect", inspect))
.icon("inspect")
.in_drawing()
.noundo()
.doc_only()
.contract(&INSPECT_CONTRACT)
.output(output_schema_for::<BalloonResult>)
.params(
"balloon: its id → {balloon, view, record: as saved, occurrence: the part it numbers, status: ok, ambiguous, suppressed or \
lost, why?, point?: where its arrow ends (its edge's middle, in the design's frame), exact, candidates?: [{edge: {body, \
index}}]}. Read-only; no model is evaluated.",
),
];
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let request: AddParams = crate::schema::read(ADD, p)?;
let record = s.drawing(ADD)?.clone();
let view = request.view.0;
record.view(view).ok_or_else(|| bad(ADD, format!("the drawing has no view {}", ids::encode(view))))?;
let edge = s.doc.edge_reference(&s.model, &request.edge.pick, &request.edge.path).map_err(|why| bad(ADD, why.to_string()))?;
let id = record.allocate_id(&mut s.ids).map_err(|e| bad(ADD, e.to_string()))?;
let balloon = Balloon { id, view, edge, at: request.at };
s.edit_drawing(ADD, |d| {
d.add_balloons([Arc::new(balloon)]);
Ok(())
})?;
answer(s, ADD, id)
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let request: EditParams = crate::schema::read(EDIT, p)?;
let id = request.balloon.0;
let changed = s.edit_drawing(EDIT, |d| {
let at = d.balloon_index(id).ok_or_else(|| bad(EDIT, format!("the drawing has no balloon {}", ids::encode(id))))?;
let Some(balloon) = d.balloons.get_mut(at).map(Arc::make_mut) else { return Ok(Vec::new()) };
let moved = request.set.at.filter(|place| *place != balloon.at);
if let Some(place) = moved {
balloon.at = place;
}
Ok(moved.map(|_| vec!["at".to_string()]).unwrap_or_default())
})?;
let reply = EditResult { balloon: ids::encode(id), changed, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let request: InspectParams = crate::schema::read(INSPECT, p)?;
answer(s, INSPECT, request.balloon.0)
}
/// Balloon `id` of the open drawing as the model has it now.
fn answer(s: &Session, cmd: &str, id: u64) -> Result<Value> {
let d = s.drawing(cmd)?;
let balloon = d.balloon(id).ok_or_else(|| bad(cmd, format!("the drawing has no balloon {}", ids::encode(id))))?;
let resolved = s.doc.resolve_balloons(&s.model, d, &[id]).map_err(|e| bad(cmd, e.to_string()))?;
let r = resolved.into_iter().next().ok_or_else(|| bad(cmd, format!("balloon {} wasn't read", ids::encode(id))))?;
let (status, why, candidates) = status_words(r.status);
let reply = BalloonResult {
balloon: ids::encode(id),
view: ids::encode(balloon.view),
record: serde_json::to_value(balloon).map_err(|e| EngineError::Other(e.to_string()))?,
occurrence: balloon.occurrence().to_vec(),
status: status.to_string(),
why,
point: r.point.map(|p| [p.x, p.y, p.z]),
exact: r.exact,
candidates,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
#[cfg(test)]
#[path = "drawing_balloon_tests.rs"]
mod tests;
@@ -0,0 +1,268 @@
use serde_json::json;
use solidcraft_doc::drawing::view::{View, ViewKind, ViewPlace};
use solidcraft_doc::drawing_view::{ViewDirection, ViewOverrides};
use solidcraft_geom::drawing_view::NamedView;
use super::*;
use crate::schema::tests::conforms;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
let reply = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
let schema = output_schema_for::<BalloonResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &reply) || !matches!(id, ADD | INSPECT), "{id}: {reply}");
reply
}
fn refused(s: &mut Session, cmd: &str, p: Value) -> String {
s.execute(cmd, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"))
}
fn saved(s: &Session) -> String {
s.drawing("test").unwrap().to_json().unwrap()
}
/// A component, Bracket, holding a box 80 x 40 x 30 mm along X, Y and Z from the origin, called
/// Box, placed once where it was made; a 10 mm cube of the design's own, Base, at x = 200; and a
/// drawing of them, open, with a front view at 1:1. The session, the view's id and the Bracket's
/// component and occurrence.
fn placed() -> (Session, u64, [u64; 2]) {
let mut s = Session::default();
let made = run(&mut s, "component.create", json!({"name": "Bracket"}));
let bracket = [made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap()];
run(&mut s, "solid.box", json!({"length": 80, "width": 40, "height": 30, "body_name": "Box"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [200, 0, 0], "operation": "new", "body_name": "Base"}));
run(&mut s, "drawing.create", json!({}));
let mut d = (**s.drawing("test").unwrap()).clone();
let id = d.allocate_id(&mut ids::IdSource::seeded(3874)).unwrap();
let (sheet, source) = (d.sheets[0].id, d.sources[0].id);
let kind = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
let settings = ViewOverrides { scale: Some("1:1".parse().unwrap()), ..ViewOverrides::default() };
d.add_views([Arc::new(View { id, sheet, kind, place: ViewPlace::At([200.0, 150.0]), settings, reference_shown_percent: 0.0 })]);
d.validate().unwrap();
s.doc_mut().put_drawing(Arc::new(d));
(s, id, bracket)
}
/// A balloon on the Box's bottom front edge, through the Bracket, at (60, 40) on paper.
fn add(s: &mut Session, view: u64, occurrence: u64) -> Value {
run(s, ADD, json!({"view": ids::encode(view), "edge": {"point": [40, 0, 0], "path": [occurrence]}, "at": [60, 40]}))
}
fn ends_at(reply: &Value, p: [f64; 3]) {
let point: Vec<f64> = reply["point"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
assert!(point.iter().zip(p).all(|(a, b)| (a - b).abs() < 1e-9), "{reply}");
}
/// A balloon numbers the part its edge is placed through, saves the edge by name (any point on it
/// picks the same record), and its arrow ends on the edge's middle, whatever point was picked. An
/// add is one undo step.
#[test]
fn a_balloon_points_at_a_placed_part_and_is_saved_by_name() {
let (mut s, view, [_, bracket]) = placed();
let before = saved(&s);
let reply = add(&mut s, view, bracket);
assert_eq!((reply["status"].as_str(), reply["exact"].as_bool()), (Some("ok"), Some(true)), "{reply}");
assert_eq!(reply["occurrence"], json!([bracket]));
assert_eq!(reply["view"], json!(ids::encode(view)));
assert_eq!(reply["record"]["edge"]["path"], json!([bracket]));
assert_eq!(reply["record"]["at"], json!([60.0, 40.0]));
ends_at(&reply, [40.0, 0.0, 0.0]);
let elsewhere = run(&mut s, ADD, json!({"view": ids::encode(view), "edge": {"point": [7.5, 0, 0], "path": [bracket]}, "at": [0, 0]}));
assert_eq!(elsewhere["record"]["edge"], reply["record"]["edge"], "saved by name, not by the point");
ends_at(&elsewhere, [40.0, 0.0, 0.0]);
run(&mut s, "edit.undo", json!({}));
let one = saved(&s);
run(&mut s, "edit.undo", json!({}));
assert_eq!(saved(&s), before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(saved(&s), one);
}
/// What a balloon can't point at is refused with its reason, and the drawing is left as it was: a
/// view the drawing doesn't have, a body of the design itself (no part), an occurrence that isn't
/// there, a point on no edge, and a place off paper.
#[test]
fn a_balloon_is_refused_what_it_cant_point_at() {
let (mut s, view, [_, bracket]) = placed();
let before = saved(&s);
let v = json!(ids::encode(view));
let nowhere = ids::encode(99);
let cases = [
(json!({"view": nowhere, "edge": {"point": [40, 0, 0], "path": [bracket]}, "at": [0, 0]}), format!("the drawing has no view {nowhere}")),
(json!({"view": v, "edge": [205, 0, 0], "at": [0, 0]}), "it points at a placed part: its edge's body is placed through an occurrence".into()),
(json!({"view": v, "edge": {"point": [40, 0, 0], "path": [999]}, "at": [0, 0]}), "no edge at [40, 0, 0]".into()),
(json!({"view": v, "edge": {"point": [40, 20, 0], "path": [bracket]}, "at": [0, 0]}), "no edge at [40, 20, 0]".into()),
(json!({"view": v, "edge": {"point": [40, 0, 0], "path": [bracket]}, "at": [5000.001, 0]}), "its place is at most 5000 mm on paper".into()),
];
for (p, why) in cases {
let e = refused(&mut s, ADD, p.clone());
assert!(e.contains(&why), "{p}: {e}");
}
assert_eq!(saved(&s), before);
}
/// An edit moves a balloon and names what changed, as one undo step; the same place again changes
/// nothing and adds no step. What it points at isn't one of its settings.
#[test]
fn an_edit_moves_a_balloon_and_the_same_place_again_changes_nothing() {
let (mut s, view, [_, bracket]) = placed();
let id = add(&mut s, view, bracket)["balloon"].clone();
let before = saved(&s);
let moved = run(&mut s, EDIT, json!({"balloon": id, "set": {"at": [-12.5, 80]}}));
assert_eq!(moved, json!({"balloon": id, "changed": ["at"]}));
assert_eq!(run(&mut s, INSPECT, json!({"balloon": id}))["record"]["at"], json!([-12.5, 80.0]));
let after = saved(&s);
for same in [json!({"at": [-12.5, 80]}), json!({})] {
assert_eq!(run(&mut s, EDIT, json!({"balloon": id, "set": same}))["changed"], json!([]));
}
run(&mut s, "edit.undo", json!({}));
assert_eq!(saved(&s), before, "one step for the move, none for the same place");
run(&mut s, "edit.redo", json!({}));
assert_eq!(saved(&s), after);
let e = refused(&mut s, EDIT, json!({"balloon": id, "set": {"edge": [40, 0, 0]}}));
assert!(e.contains("edge"), "{e}");
let e = refused(&mut s, EDIT, json!({"balloon": ids::encode(99), "set": {"at": [0, 0]}}));
assert!(e.contains("the drawing has no balloon"), "{e}");
assert_eq!(saved(&s), after);
}
/// Inspect says where a balloon stands with the model now: a notch across its edge splits it, so it
/// lists both pieces and has no middle; undone, it is whole again; an occurrence that is gone is
/// lost, said so, and never moved to another part. The model is changed with the drawing closed.
#[test]
fn inspect_says_where_a_balloon_stands_with_the_model() {
let (mut s, view, [component, bracket]) = placed();
let drawing = json!({"drawing": ids::encode(s.drawing("test").unwrap().id)});
let id = add(&mut s, view, bracket)["balloon"].clone();
run(&mut s, "drawing.close", json!({}));
run(&mut s, "component.activate", json!({"component": component}));
run(&mut s, "solid.box", json!({"length": 10, "width": 5, "height": 5, "corner": [35, -1, -1], "operation": "cut", "targets": ["Box"]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "drawing.open", drawing.clone());
let split = run(&mut s, INSPECT, json!({"balloon": id}));
assert_eq!(split["status"], "ambiguous", "{split}");
assert_eq!(split["candidates"].as_array().map(Vec::len), Some(2), "{split}");
assert!(split.get("point").is_none(), "no one middle: {split}");
run(&mut s, "drawing.close", json!({}));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "drawing.open", drawing);
let whole = run(&mut s, INSPECT, json!({"balloon": id}));
assert_eq!(whole["status"], "ok", "{whole}");
ends_at(&whole, [40.0, 0.0, 0.0]);
let mut d = (**s.drawing("test").unwrap()).clone();
let b = d.balloons.first_mut().map(Arc::make_mut).unwrap();
b.edge.path = vec![999];
s.doc_mut().put_drawing(Arc::new(d));
let gone = run(&mut s, INSPECT, json!({"balloon": id}));
assert_eq!((gone["status"].as_str(), gone["occurrence"].clone()), (Some("lost"), json!([999])), "{gone}");
assert!(gone["why"].as_str().is_some_and(|why| !why.is_empty()) && gone.get("point").is_none(), "{gone}");
let e = refused(&mut s, INSPECT, json!({"balloon": ids::encode(99)}));
assert!(e.contains("the drawing has no balloon"), "{e}");
}
/// A balloon put on a piece of a cut edge is found through the piece's numbered name, so not exact.
/// Once the cut is suppressed, the two faces the name names meet in one whole edge again, found
/// exactly, and the arrow moves to its middle.
#[test]
fn a_balloon_on_a_piece_is_not_exact_until_the_edge_is_whole_again() {
let (mut s, view, [component, bracket]) = placed();
let drawing = json!({"drawing": ids::encode(s.drawing("test").unwrap().id)});
run(&mut s, "drawing.close", json!({}));
run(&mut s, "component.activate", json!({"component": component}));
let cut = run(
&mut s,
"solid.box",
json!({"length": 10, "width": 5, "height": 5, "corner": [35, -1, -1], "operation": "cut", "targets": ["Box"]}),
)["feature"]
.clone();
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "drawing.open", drawing.clone());
let piece = run(&mut s, ADD, json!({"view": ids::encode(view), "edge": {"point": [10, 0, 0], "path": [bracket]}, "at": [60, 40]}));
assert_eq!((piece["status"].as_str(), piece["exact"].as_bool()), (Some("ok"), Some(false)), "{piece}");
ends_at(&piece, [17.5, 0.0, 0.0]);
run(&mut s, "drawing.close", json!({}));
run(&mut s, "component.activate", json!({"component": component}));
run(&mut s, "timeline.suppress", json!({"feature": cut, "suppressed": true}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "drawing.open", drawing);
let whole = run(&mut s, INSPECT, json!({"balloon": piece["balloon"]}));
assert_eq!((whole["status"].as_str(), whole["exact"].as_bool()), (Some("ok"), Some(true)), "{whole}");
ends_at(&whole, [40.0, 0.0, 0.0]);
}
/// Every place a value can go in `v`, as JSON pointers: `v` itself, then each member and item.
fn places(v: &Value, at: String, out: &mut Vec<String>) {
out.push(at.clone());
match v {
Value::Object(o) => o.iter().for_each(|(k, x)| places(x, format!("{at}/{k}"), out)),
Value::Array(l) => l.iter().enumerate().for_each(|(i, x)| places(x, format!("{at}/{i}"), out)),
_ => {}
}
}
/// A hostile value in each place of each command's parameters, at every depth (the edge's pick and
/// path, the place, the edit's settings): never a panic, and the drawing keeps its rules.
#[test]
fn hostile_balloon_params_never_panic() {
let (mut s, view, [_, bracket]) = placed();
let id = add(&mut s, view, bracket)["balloon"].clone();
let v = json!(ids::encode(view));
let calls = [
(ADD, json!({"view": v, "edge": {"point": [40, 0, 0], "path": [bracket]}, "at": [1, 2]})),
(ADD, json!({"view": v, "edge": {"body": "Box", "index": 0, "path": [bracket]}, "at": [1, 2]})),
(EDIT, json!({"balloon": id, "set": {"at": [3, 4]}})),
(INSPECT, json!({"balloon": id})),
];
let mut values = crate::tests::hostile_values();
values.extend([json!({"body": "Box", "index": u64::MAX}), Value::Array(vec![json!(1); 1000]), json!([1e308, 0]), json!("x".repeat(600))]);
let mut internal = Vec::new();
for (cmd, template) in calls {
let mut at = Vec::new();
places(&template, String::new(), &mut at);
for (place, value) in at.iter().flat_map(|place| values.iter().map(move |value| (place, value))) {
let mut p = template.clone();
if let Some(slot) = p.pointer_mut(place) {
*slot = value.clone();
}
let mut copy = s.scratch();
if let Err(crate::EngineError::Internal(_, msg)) = copy.execute(cmd, &p) {
internal.push(format!("{cmd} {p}: {msg}"));
}
copy.doc.drawings.iter().for_each(|d| d.validate().unwrap());
}
}
assert!(internal.is_empty(), "balloon commands panicked:\n{}", internal.join("\n"));
}
/// A view lists its balloons. A copied view's balloons are copies with ids of their own, pointing
/// at the same part from the same place, and one undo step takes the copy and them out. A sheet
/// with them on it is refused, naming them, until its contents go with it.
#[test]
fn balloons_go_with_their_view_when_it_is_copied_and_with_its_sheet() {
let (mut s, view, [_, bracket]) = placed();
let original = add(&mut s, view, bracket);
let listed = |s: &mut Session, view: &Value| run(s, "drawing.view.inspect", json!({"view": view}))["balloons"].clone();
assert_eq!(listed(&mut s, &json!(ids::encode(view))), json!([original["balloon"]]));
let before = saved(&s);
let sheet = run(&mut s, "drawing.sheet.add", json!({"name": "Sheet 2"}))["sheet"].clone();
let reply = run(&mut s, "drawing.view.copy_to_sheet", json!({"view": ids::encode(view), "sheet": sheet, "position_mm": [100, 100]}));
let copy = reply["view"].clone();
let copied = listed(&mut s, &copy);
let [balloon] = copied.as_array().map(Vec::as_slice).unwrap_or_default() else { panic!("one copied balloon: {copied}") };
assert_ne!(balloon, &original["balloon"]);
let read = run(&mut s, INSPECT, json!({"balloon": balloon}));
assert_eq!((read["status"].as_str(), &read["view"]), (Some("ok"), &copy), "{read}");
assert_eq!((&read["record"]["edge"], &read["record"]["at"]), (&original["record"]["edge"], &original["record"]["at"]));
assert_eq!(listed(&mut s, &json!(ids::encode(view))), json!([original["balloon"]]), "the original keeps its own");
let e = refused(&mut s, "drawing.sheet.delete", json!({"sheet": sheet}));
let named = format!("1 view (`{}`) and 1 balloon (`{}`)", copy.as_str().unwrap(), balloon.as_str().unwrap());
assert!(e.contains(&named), "{e}");
run(&mut s, "drawing.sheet.delete", json!({"sheet": sheet, "contents": "delete"}));
let e = refused(&mut s, INSPECT, json!({"balloon": balloon}));
assert!(e.contains("the drawing has no balloon"), "{e}");
for _ in 0..3 {
run(&mut s, "edit.undo", json!({}));
}
assert_eq!(saved(&s), before);
}
+330
View File
@@ -0,0 +1,330 @@
//! `drawing.dimension.add`, `.edit` and `.inspect` (W19d, #4896): a dimension on a saved view of
//! the open drawing, its settings, and what it reads now. What a dimension measures is picked (a
//! point, or an edge's index) and saved by name, never by the point picked
//! ([`solidcraft_doc::Document::edge_reference`]); its value comes from the model each time it is
//! read ([`solidcraft_doc::Document::resolve_dimensions`]). Adding and editing are one undo step
//! of the drawing each, and none of the commands evaluates the model.
use std::sync::Arc;
use serde_json::Value;
use solidcraft_doc::drawing::Drawing;
use solidcraft_doc::drawing::dimension::{Candidate, Dimension, DimensionKind, Silhouette, Status, TextPlace, check_chain_length};
use solidcraft_doc::ids;
use solidcraft_geom::Vec3;
use solidcraft_geom::drawing_measure::{LinearDirection, Reading};
use super::CommandSpec;
use crate::params::bad;
use crate::schema::drawing_dimension::{
AddParams, CandidateResult, CornerParam, DimensionResult, DimensionSet, EdgeParam, EditParams, EditResult, FaceParam, InspectParams, KindParam,
MemberResult, SilhouetteParam,
};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const ADD: &str = "drawing.dimension.add";
const ADD_CONTRACT: TypedContract<AddParams> = TypedContract::new();
const EDIT: &str = "drawing.dimension.edit";
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
const INSPECT: &str = "drawing.dimension.inspect";
const INSPECT_CONTRACT: TypedContract<InspectParams> = TypedContract::new();
/// How far a new dimension's line is from what it measures, on paper, until a dimension style
/// gives it (D1).
const DEFAULT_OFFSET_MM: f64 = 10.0;
/// How many decimals a new dimension shows unless told.
const DEFAULT_DECIMALS: u8 = 2;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new(ADD, "Add Drawing Dimension", crate::checked!("drawing.dimension.add", add))
.icon("dimension")
.in_drawing()
.doc_only()
.contract(&ADD_CONTRACT)
.output(output_schema_for::<DimensionResult>)
.params(
"view: a saved view's id; kind: {linear: {a, b: corners, direction?: aligned (default), horizontal, vertical or {along: [x, y]}, \
reading?: projected (default) or true}}, {length: {edge, direction?, reading?}}, {angle: {a, b: straight edges, reading?, \
other_side?}}, {radial: {edge: a circle or an arc the view sees face on, diameter?: by default for a whole circle or \
half a turn or more}}, {cylinder: {face: a face of a whole cylinder the view doesn't see end-on, read as Ø between its \
silhouettes}}, {silhouettes: {a, b: {face, side: near or far, the silhouette nearer the other cylinder or farther}, \
direction?: as a linear one's, aligned being square to them}: two of one cylinder make its cylinder dimension} or \
{chain: {points: [3 to 64 corners, in order along one line], direction?: as a linear one's, aligned being first to \
last}}; an edge is a point on it [x, y, z] in the design's frame, {point} or {body, index}, and a \
corner a point, {point}, {edges: [2 to 16 edges]} or {centre: an edge that is a circle or an arc}, and a face a point on \
it, {point} or {body, index}, each with path?: the \
occurrences its body is placed through, from the top; offset_mm?: the dimension line's distance on paper (default 10); \
text?: centred (default) or {at: [x, y]} from the view's centre; decimals?: 0..6 (default 2); override_text? (not a \
chain's) → what drawing.dimension.inspect answers for it. Saved by the edges' names, never the points picked; one the \
view can't measure is refused. One undo step; no model is evaluated.",
),
CommandSpec::new(EDIT, "Edit Drawing Dimension", crate::checked!("drawing.dimension.edit", edit))
.icon("dimension")
.in_drawing()
.doc_only()
.contract(&EDIT_CONTRACT)
.output(output_schema_for::<EditResult>)
.params(
"dimension: its id; set: {offset_mm?, text?: centred or {at: [x, y]}, decimals?: 0..6, override_text?: text, or null to show \
the number again; direction?, reading?: a linear or length dimension's, reading? and other_side?: an angle's, diameter?: a \
radial dimension's, direction?: a chain's or a silhouettes one's} → \
{dimension, changed: [fields]}. What it measures stays: to measure something else, add a new one. One undo step, none \
when nothing changes; no model is evaluated.",
),
CommandSpec::new(INSPECT, "Inspect Drawing Dimension", crate::checked!("drawing.dimension.inspect", inspect))
.icon("inspect")
.in_drawing()
.noundo()
.doc_only()
.contract(&INSPECT_CONTRACT)
.output(output_schema_for::<DimensionResult>)
.params(
"dimension: its id → {dimension, view, record: as saved, status: ok, overridden, ambiguous, suppressed, lost or unmeasurable, \
why?, value? (model mm, or degrees for an angle; a chain's overall), foreshortened?, text? (none for a chain), \
members?: a chain's [{value, text}] from its first point, last_good? (the value it last read, kept when the design \
is saved, when it has none now), exact, candidates?: [{edge: {body, index}}, {corner: [x, y, z]} or {face: {body, index}}]}. \
Read-only; no model is evaluated.",
),
];
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let request: AddParams = crate::schema::read(ADD, p)?;
let record = s.drawing(ADD)?.clone();
let view = request.view.0;
record.view(view).ok_or_else(|| bad(ADD, format!("the drawing has no view {}", ids::encode(view))))?;
let (doc, model) = (&s.doc, &s.model);
let edge = |e: &EdgeParam| doc.edge_reference(model, &e.pick, &e.path).map_err(|why| bad(ADD, why.to_string()));
let corner = |c: &CornerParam| doc.corner_reference(model, &c.pick, &c.path).map_err(|why| bad(ADD, why.to_string()));
let face = |f: &FaceParam| doc.face_reference(model, &f.pick, &f.path).map_err(|why| bad(ADD, why.to_string()));
let choose_diameter = matches!(request.kind, KindParam::Radial { diameter: None, .. });
let kind = match request.kind {
KindParam::Linear { a, b, direction, reading } => DimensionKind::Linear {
a: corner(&a)?,
b: corner(&b)?,
direction: direction.unwrap_or(LinearDirection::Aligned),
reading: reading.unwrap_or(Reading::Projected),
},
KindParam::Length { edge: e, direction, reading } => DimensionKind::Length {
edge: edge(&e)?,
direction: direction.unwrap_or(LinearDirection::Aligned),
reading: reading.unwrap_or(Reading::Projected),
},
KindParam::Angle { a, b, reading, other_side } => {
DimensionKind::Angle { a: edge(&a)?, b: edge(&b)?, reading: reading.unwrap_or(Reading::Projected), other_side }
}
KindParam::Radial { edge: e, diameter } => DimensionKind::Radial { edge: edge(&e)?, diameter: diameter.unwrap_or(false) },
KindParam::Cylinder { face: f } => DimensionKind::Cylinder { face: face(&f)? },
KindParam::Silhouettes { a, b, direction } => {
let silhouette = |s: &SilhouetteParam| face(&s.face).map(|face| Silhouette { face, side: s.side });
doc.silhouettes_kind(model, silhouette(&a)?, silhouette(&b)?, direction).map_err(|why| bad(ADD, why.to_string()))?
}
KindParam::Chain { points, direction } => {
// Counted before any point is looked up.
check_chain_length(points.len()).map_err(|why| bad(ADD, why.to_string()))?;
let points = points.iter().map(corner).collect::<Result<_>>()?;
DimensionKind::Chain { points, direction: direction.unwrap_or(LinearDirection::Aligned) }
}
};
let id = record.allocate_id(&mut s.ids).map_err(|e| bad(ADD, e.to_string()))?;
let mut dimension = Dimension {
id,
view,
kind,
offset_mm: request.offset_mm.unwrap_or(DEFAULT_OFFSET_MM),
text: request.text.unwrap_or(TextPlace::Centred),
decimals: request.decimals.unwrap_or(DEFAULT_DECIMALS),
override_text: request.override_text,
last_good: None,
};
// Read before it is added: a dimension its view can't measure would show nothing.
let mut staged = Drawing::clone(&record);
staged.add_dimensions([Arc::new(dimension.clone())]);
let read = s.doc.resolve_dimensions(&s.model, &staged, &[id]).map_err(|e| bad(ADD, e.to_string()))?;
if let Some(Status::Unmeasurable(why)) = read.first().map(|r| &r.status) {
return Err(bad(ADD, why.clone()));
}
if choose_diameter && let DimensionKind::Radial { diameter, .. } = &mut dimension.kind {
*diameter = read.first().is_some_and(|r| diameter_by_default(&r.points));
}
s.edit_drawing(ADD, |d| {
d.add_dimensions([Arc::new(dimension)]);
Ok(())
})?;
answer(s, ADD, id)
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let request: EditParams = crate::schema::read(EDIT, p)?;
let id = request.dimension.0;
let changed = s.edit_drawing(EDIT, |d| change(d, id, &request.set))?;
let reply = EditResult { dimension: ids::encode(id), changed, param_warnings: Vec::new() };
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// A new radial dimension's default from its arc as read (its centre, start and middle): a diameter
/// for a whole circle or an arc of half a turn or more, as a hole's rim is (the kernel makes it of
/// two halves), else a radius.
fn diameter_by_default(points: &[Vec3]) -> bool {
let [centre, start, middle, ..] = points else { return false };
let (from, to) = (*start - *centre, *middle - *centre);
// The middle is half the arc's turn from its start: a quarter turn or more away.
from.dot(to) <= from.dot(from) * 1e-9
}
/// `set` made on dimension `id` of `d`: the fields it changes, by name.
fn change(d: &mut Drawing, id: u64, set: &DimensionSet) -> Result<Vec<String>> {
let at = d.dimension_index(id).ok_or_else(|| bad(EDIT, format!("the drawing has no dimension {}", ids::encode(id))))?;
let Some(dimension) = d.dimensions.get_mut(at).map(Arc::make_mut) else { return Ok(Vec::new()) };
let before = dimension.clone();
if let Some(offset) = set.offset_mm {
dimension.offset_mm = offset;
}
if let Some(text) = set.text {
dimension.text = text;
}
if let Some(decimals) = set.decimals {
dimension.decimals = decimals;
}
if let Some(text) = &set.override_text {
dimension.override_text = text.clone();
}
if set.diameter.is_some() && !matches!(dimension.kind, DimensionKind::Radial { .. }) {
return Err(bad(EDIT, "`diameter` is a radial dimension's"));
}
match &mut dimension.kind {
DimensionKind::Linear { direction, reading, .. } | DimensionKind::Length { direction, reading, .. } => {
if set.other_side.is_some() {
return Err(bad(EDIT, "`other_side` is an angle's"));
}
*direction = set.direction.unwrap_or(*direction);
*reading = set.reading.unwrap_or(*reading);
}
DimensionKind::Angle { reading, other_side, .. } => {
if set.direction.is_some() {
return Err(bad(EDIT, "an angle has no `direction`: it measures between its edges' lines"));
}
*reading = set.reading.unwrap_or(*reading);
*other_side = set.other_side.unwrap_or(*other_side);
}
DimensionKind::Radial { diameter, .. } => {
if set.direction.is_some() || set.reading.is_some() || set.other_side.is_some() {
return Err(bad(EDIT, "a radial dimension has only `diameter`: it reads its circle as the view sees it face on"));
}
*diameter = set.diameter.unwrap_or(*diameter);
}
DimensionKind::Chain { direction, .. } => {
if set.reading.is_some() || set.other_side.is_some() {
return Err(bad(EDIT, "a chain has only `direction`: it reads as the view shows it"));
}
*direction = set.direction.unwrap_or(*direction);
}
DimensionKind::Silhouettes { direction, .. } => {
if set.reading.is_some() || set.other_side.is_some() {
return Err(bad(EDIT, "a dimension between silhouettes has only `direction`: it reads as the view shows them"));
}
*direction = set.direction.unwrap_or(*direction);
}
DimensionKind::Cylinder { .. } => {
if set.direction.is_some() || set.reading.is_some() || set.other_side.is_some() {
return Err(bad(EDIT, "a cylinder's diameter has no direction, reading or side: it reads between its silhouettes"));
}
}
}
Ok(changed_fields(&before, dimension))
}
/// The direction, reading, side and diameter of a dimension's kind, where it has them.
fn kind_settings(kind: &DimensionKind) -> (Option<LinearDirection>, Option<Reading>, bool, bool) {
match kind {
DimensionKind::Linear { direction, reading, .. } | DimensionKind::Length { direction, reading, .. } => {
(Some(*direction), Some(*reading), false, false)
}
DimensionKind::Angle { reading, other_side, .. } => (None, Some(*reading), *other_side, false),
DimensionKind::Radial { diameter, .. } => (None, None, false, *diameter),
DimensionKind::Chain { direction, .. } | DimensionKind::Silhouettes { direction, .. } => (Some(*direction), None, false, false),
DimensionKind::Cylinder { .. } => (None, None, false, false),
}
}
/// The names of the fields that differ between `a` and `b`, two states of one dimension.
fn changed_fields(a: &Dimension, b: &Dimension) -> Vec<String> {
let (x, y) = (kind_settings(&a.kind), kind_settings(&b.kind));
let fields = [
("offset_mm", a.offset_mm != b.offset_mm),
("text", a.text != b.text),
("decimals", a.decimals != b.decimals),
("override_text", a.override_text != b.override_text),
("direction", x.0 != y.0),
("reading", x.1 != y.1),
("other_side", x.2 != y.2),
("diameter", x.3 != y.3),
];
fields.into_iter().filter(|(_, changed)| *changed).map(|(name, _)| name.to_string()).collect()
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let request: InspectParams = crate::schema::read(INSPECT, p)?;
answer(s, INSPECT, request.dimension.0)
}
/// Dimension `id` of the open drawing as the model has it now. A value read is kept as its last
/// good one ([`Session::dimension_values`]); with none this session, the file's is its last good one.
fn answer(s: &mut Session, cmd: &str, id: u64) -> Result<Value> {
let d = s.drawing(cmd)?.clone();
let dimension = d.dimension(id).ok_or_else(|| bad(cmd, format!("the drawing has no dimension {}", ids::encode(id))))?;
let resolved = s.doc.resolve_dimensions(&s.model, &d, &[id]).map_err(|e| bad(cmd, e.to_string()))?;
let r = resolved.into_iter().next().ok_or_else(|| bad(cmd, format!("dimension {} wasn't read", ids::encode(id))))?;
let last_good = match r.measured {
Some(m) => {
s.dimension_values.insert((d.id, id), m.value);
None
}
None => s.dimension_values.get(&(d.id, id)).copied().or(dimension.last_good),
};
let (status, why, candidates) = status_words(r.status);
let angle = matches!(dimension.kind, DimensionKind::Angle { .. });
let shown = |value: f64| if angle { value.to_degrees() } else { value };
let reply = DimensionResult {
dimension: ids::encode(id),
view: ids::encode(dimension.view),
record: serde_json::to_value(dimension).map_err(|e| EngineError::Other(e.to_string()))?,
status: status.to_string(),
why,
value: r.measured.map(|m| shown(m.value)),
foreshortened: r.measured.map(|m| m.foreshortened),
text: r.text,
members: r.members.into_iter().map(|m| MemberResult { value: m.measured.value, text: m.text }).collect(),
last_good: last_good.map(shown),
exact: r.exact,
candidates,
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
/// How a command's answer says where a reference stands with the model: the status's word, why
/// it is lost or can't be measured, and the candidates when several things match. Balloons say it
/// the same way.
pub(super) fn status_words(status: Status) -> (&'static str, Option<String>, Vec<CandidateResult>) {
match status {
Status::Ok => ("ok", None, Vec::new()),
Status::Overridden => ("overridden", None, Vec::new()),
Status::Ambiguous(found) => ("ambiguous", None, found.into_iter().map(candidate).collect()),
Status::Suppressed => ("suppressed", None, Vec::new()),
Status::Lost(why) => ("lost", Some(why), Vec::new()),
Status::Unmeasurable(why) => ("unmeasurable", Some(why), Vec::new()),
}
}
fn candidate(c: Candidate) -> CandidateResult {
match c {
Candidate::Edge { body, index } => CandidateResult::Edge { body, index },
Candidate::Corner(p) => CandidateResult::Corner([p.x, p.y, p.z]),
Candidate::Face { body, index } => CandidateResult::Face { body, index },
}
}
#[cfg(test)]
#[path = "drawing_dimension_tests.rs"]
mod tests;
@@ -0,0 +1,799 @@
use std::collections::BTreeSet;
use serde_json::json;
use solidcraft_doc::drawing::view::{View, ViewKind, ViewPlace};
use solidcraft_doc::drawing_view::{ViewDirection, ViewOverrides};
use solidcraft_geom::Vec3;
use solidcraft_geom::drawing_view::NamedView;
use super::*;
use crate::schema::tests::conforms;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
let reply = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
let schema = output_schema_for::<DimensionResult>();
assert!(conforms(schema.as_value(), schema.as_value(), &reply) || !id.starts_with("drawing.dimension"), "{id}: {reply}");
reply
}
fn refused(s: &mut Session, p: Value) -> String {
s.execute(ADD, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"))
}
/// A box 80 x 40 x 30 mm along X, Y and Z from the origin, called Box, and a drawing of it, open,
/// with its front view at 1:1: the session and the view's id.
fn front_view() -> (Session, u64) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 80, "width": 40, "height": 30, "body_name": "Box"}));
drawn_from_the_front(s)
}
/// A drawing of `s`, open, with its front view at 1:1: the session and the view's id.
fn drawn_from_the_front(mut s: Session) -> (Session, u64) {
run(&mut s, "drawing.create", json!({}));
let mut d = (**s.drawing("test").unwrap()).clone();
let id = d.allocate_id(&mut ids::IdSource::seeded(4896)).unwrap();
let (sheet, source) = (d.sheets[0].id, d.sources[0].id);
let kind = ViewKind::Base { source, view: ViewDirection::Named(NamedView::Front) };
let settings = ViewOverrides { scale: Some("1:1".parse().unwrap()), ..ViewOverrides::default() };
d.add_views([Arc::new(View { id, sheet, kind, place: ViewPlace::At([200.0, 150.0]), settings, reference_shown_percent: 0.0 })]);
d.validate().unwrap();
s.doc_mut().put_drawing(Arc::new(d));
(s, id)
}
/// The index of the box's edge from `a` to `b`, as `{body, index}` names it.
fn edge_index(s: &Session, a: [f64; 3], b: [f64; 3]) -> usize {
let (a, b) = (Vec3::from(a), Vec3::from(b));
let state = s.model.state();
let mesh = state.body("Box").unwrap().mesh();
let ends = |line: &Vec<Vec3>| (line[0], line[line.len() - 1]);
let joins = |(p, q): (Vec3, Vec3)| (p.dist(a) < 1e-9 && q.dist(b) < 1e-9) || (p.dist(b) < 1e-9 && q.dist(a) < 1e-9);
mesh.edges.iter().position(|line| joins(ends(line))).unwrap()
}
/// The index of the box's edge whose every point is `radius` from `centre`, as `{body, index}`
/// names it: one of a circle's or an arc's.
fn round_index(s: &Session, centre: [f64; 3], radius: f64) -> usize {
let centre = Vec3::from(centre);
let state = s.model.state();
let mesh = state.body("Box").unwrap().mesh();
mesh.edges.iter().position(|line| line.iter().all(|p| (p.dist(centre) - radius).abs() < 1e-6)).unwrap()
}
fn edit(s: &mut Session, dimension: &Value, set: Value) -> Value {
s.execute(EDIT, &json!({"dimension": dimension, "set": set})).unwrap_or_else(|e| panic!("{EDIT}: {e}"))
}
fn add(s: &mut Session, view: u64, kind: Value) -> Value {
run(s, ADD, json!({"view": ids::encode(view), "kind": kind}))
}
fn reads(reply: &Value, value: f64, text: &str) {
assert_eq!((reply["status"].as_str(), reply["text"].as_str()), (Some("ok"), Some(text)), "{reply}");
assert!((reply["value"].as_f64().unwrap() - value).abs() < 1e-9, "{reply}");
}
/// A dimension added by points or by edges is saved by the edges' names, and reads as the front
/// view shows the box: its bottom front corners 80 apart, its top front edge 80 long, and that
/// edge square to the left front edge.
#[test]
fn a_dimension_is_added_by_points_or_edges_and_saved_by_name() {
let (mut s, view) = front_view();
let steps = s.undo.len();
let by_points = add(&mut s, view, json!({"linear": {"a": [0, 0, 0], "b": [80, 0, 0], "direction": "horizontal"}}));
reads(&by_points, 80.0, "80.00");
assert_eq!((by_points["exact"].clone(), by_points["foreshortened"].clone()), (json!(true), json!(false)));
assert_eq!(s.undo.len(), steps + 1, "one undo step");
let record = &by_points["record"];
assert_eq!((&record["offset_mm"], &record["text"], &record["decimals"]), (&json!(10.0), &json!("centred"), &json!(2)), "the defaults");
let corner = &record["kind"]["linear"]["a"];
assert_eq!((corner["body"].as_str(), corner["edges"].as_array().map(Vec::len)), (Some("Box"), Some(3)), "a box corner: three edges");
assert!(corner.get("point").is_none() && corner.to_string().contains('|'), "names, not the point: {corner}");
// The same corners by two of their edges each.
let bottom = edge_index(&s, [0.0, 0.0, 0.0], [80.0, 0.0, 0.0]);
let (left, right) = (edge_index(&s, [0.0, 0.0, 0.0], [0.0, 0.0, 30.0]), edge_index(&s, [80.0, 0.0, 0.0], [80.0, 0.0, 30.0]));
let edge = |index: usize| json!({"body": "Box", "index": index});
let by_edges = add(&mut s, view, json!({"linear": {"a": {"edges": [edge(bottom), edge(left)]}, "b": {"edges": [edge(right), edge(bottom)]}}}));
reads(&by_edges, 80.0, "80.00");
// The top front edge's length, by a point on it and by its index, saves the same name.
let top = edge_index(&s, [0.0, 0.0, 30.0], [80.0, 0.0, 30.0]);
let at = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}));
let indexed = add(&mut s, view, json!({"length": {"edge": edge(top)}}));
reads(&at, 80.0, "80.00");
assert_eq!(at["record"]["kind"], indexed["record"]["kind"]);
let square = run(&mut s, ADD, json!({"view": ids::encode(view), "kind": {"angle": {"a": [40, 0, 30], "b": [0, 0, 15]}}, "decimals": 1}));
reads(&square, 90.0, "90.0°");
// Inspect reads each one back as add answered it.
let id = by_points["dimension"].clone();
assert_eq!(run(&mut s, INSPECT, json!({"dimension": id})), by_points);
// The view lists them, in id order.
let mut added: Vec<u64> =
[&by_points, &by_edges, &at, &indexed, &square].iter().filter_map(|r| r["dimension"].as_str().and_then(ids::decode)).collect();
added.sort_unstable();
let listed = run(&mut s, "drawing.view.inspect", json!({"view": ids::encode(view)}))["dimensions"].clone();
assert_eq!(listed, json!(added.into_iter().map(ids::encode).collect::<Vec<_>>()));
}
/// An undone add takes the dimension out, and a redo puts it back.
#[test]
fn an_added_dimension_is_one_undo_step() {
let (mut s, view) = front_view();
let before = s.drawing("test").unwrap().to_json().unwrap();
let p = json!({"view": ids::encode(view), "kind": {"length": {"edge": [40, 0, 30]}}, "offset_mm": -12.5, "text": {"at": [0, 40]}});
let added = run(&mut s, ADD, p);
let after = s.drawing("test").unwrap().to_json().unwrap();
assert_eq!((added["record"]["offset_mm"].clone(), added["record"]["text"].clone()), (json!(-12.5), json!({"at": [0.0, 40.0]})));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), after);
}
/// A copied view's dimensions are copies with ids of their own, measuring the same, and one undo
/// step takes the copy and them out.
#[test]
fn a_copied_view_takes_copies_of_its_dimensions() {
let (mut s, view) = front_view();
let original = run(&mut s, ADD, json!({"view": ids::encode(view), "kind": {"length": {"edge": [40, 0, 30]}}, "text": {"at": [0, 25]}}));
let before = s.drawing("test").unwrap().to_json().unwrap();
let reply = run(&mut s, "drawing.view.copy_to_sheet", json!({"view": ids::encode(view), "sheet": "Sheet 1", "position_mm": [100, 100]}));
let copy = reply["view"].clone();
let listed = run(&mut s, "drawing.view.inspect", json!({"view": copy}))["dimensions"].clone();
let [copied] = listed.as_array().map(Vec::as_slice).unwrap_or_default() else { panic!("one copied dimension: {listed}") };
assert_ne!(copied, &original["dimension"]);
let read = run(&mut s, INSPECT, json!({"dimension": copied}));
reads(&read, 80.0, "80.00");
assert_eq!(read["view"], copy);
let (mine, theirs) = (&read["record"], &original["record"]);
assert_eq!((&mine["kind"], &mine["text"], &mine["offset_mm"]), (&theirs["kind"], &theirs["text"], &theirs["offset_mm"]));
// The original keeps its own.
assert_eq!(run(&mut s, "drawing.view.inspect", json!({"view": ids::encode(view)}))["dimensions"], json!([original["dimension"]]));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
}
/// An edit changes only what it is given and names only what changed; the same values again
/// change nothing and add no undo step, and `null` shows the number again.
#[test]
fn an_edit_names_what_it_changed_and_the_same_again_changes_nothing() {
let (mut s, view) = front_view();
let added = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}));
let id = added["dimension"].clone();
let steps = s.undo.len();
let set = json!({"offset_mm": -15, "text": {"at": [5, 30]}, "decimals": 1, "override_text": "SEE NOTE", "reading": "true"});
assert_eq!(edit(&mut s, &id, set.clone())["changed"], json!(["offset_mm", "text", "decimals", "override_text", "reading"]));
assert_eq!(s.undo.len(), steps + 1, "one undo step");
let read = run(&mut s, INSPECT, json!({"dimension": id}));
assert_eq!((read["status"].as_str(), read["text"].as_str()), (Some("overridden"), Some("SEE NOTE")), "{read}");
assert_eq!(read["record"]["kind"]["length"]["edge"], added["record"]["kind"]["length"]["edge"], "what it measures stays");
let bytes = s.drawing("test").unwrap().to_json().unwrap();
assert_eq!(edit(&mut s, &id, set)["changed"], json!([]));
assert_eq!((s.undo.len(), s.drawing("test").unwrap().to_json().unwrap()), (steps + 1, bytes), "nothing changed: no undo step");
assert_eq!(edit(&mut s, &id, json!({"override_text": null}))["changed"], json!(["override_text"]));
reads(&run(&mut s, INSPECT, json!({"dimension": id})), 80.0, "80.0");
}
/// A radial dimension on a hole through the box is a diameter by default (its rim is two halves,
/// each half a turn), and on a fillet's quarter arc a radius; as asked, the hole reads its radius,
/// and an edit turns one into the other and back. It takes no direction, reading or side.
#[test]
fn a_radial_dimension_reads_a_hole_and_an_edit_makes_it_a_diameter() {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
run(&mut s, "drawing.close", json!({}));
run(&mut s, "solid.cylinder", json!({"radius": 6, "height": 42, "base": [40, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
run(&mut s, "solid.fillet", json!({"edges": [[80, 20, 30]], "radius": 5, "body": "Box"}));
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
let hole = json!({"body": "Box", "index": round_index(&s, [40.0, 0.0, 15.0], 6.0)});
let across = add(&mut s, view, json!({"radial": {"edge": hole}}));
reads(&across, 12.0, "Ø12.00");
assert_eq!(across["record"]["kind"]["radial"]["diameter"], json!(true), "{across}");
let fillet = json!({"body": "Box", "index": round_index(&s, [75.0, 0.0, 25.0], 5.0)});
let arc = add(&mut s, view, json!({"radial": {"edge": fillet}}));
reads(&arc, 5.0, "R5.00");
assert_eq!(arc["record"]["kind"]["radial"].get("diameter"), None, "{arc}");
reads(&add(&mut s, view, json!({"radial": {"edge": fillet, "diameter": true}})), 10.0, "Ø10.00");
let radius = add(&mut s, view, json!({"radial": {"edge": hole, "diameter": false}}));
reads(&radius, 6.0, "R6.00");
let id = radius["dimension"].clone();
assert_eq!(edit(&mut s, &id, json!({"diameter": true}))["changed"], json!(["diameter"]));
reads(&run(&mut s, INSPECT, json!({"dimension": id})), 12.0, "Ø12.00");
assert_eq!(edit(&mut s, &id, json!({"diameter": false}))["changed"], json!(["diameter"]));
reads(&run(&mut s, INSPECT, json!({"dimension": id})), 6.0, "R6.00");
let before = s.drawing("test").unwrap().to_json().unwrap();
for set in [json!({"direction": "horizontal"}), json!({"reading": "true"}), json!({"other_side": true})] {
let p = json!({"dimension": id, "set": set});
let e = s.execute(EDIT, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
assert!(e.contains("a radial dimension has only `diameter`"), "{p}: {e}");
}
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
}
/// A point on a curved edge names it wherever it falls between the edge's chords, a little way off it
/// names nothing, and a point on a straight edge is that edge however near a curved one is.
#[test]
fn a_point_on_a_curved_edge_finds_it_between_its_chords() {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
run(&mut s, "drawing.close", json!({}));
run(&mut s, "solid.cylinder", json!({"radius": 6, "height": 42, "base": [40, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
run(&mut s, "solid.fillet", json!({"edges": [[80, 20, 30]], "radius": 5, "body": "Box"}));
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
let rim = |radius: f64, degrees: f64| {
let (sin, cos) = degrees.to_radians().sin_cos();
json!([40.0 + radius * cos, 0.0, 15.0 + radius * sin])
};
// The hole's rim is two half circles, and each point names one of them. Whole steps never land on
// where they meet, which names both and is refused.
let mut named = BTreeSet::new();
for step in 0..55 {
let at = add(&mut s, view, json!({"radial": {"edge": rim(6.0, 3.3 + 6.5 * f64::from(step))}}));
reads(&at, 12.0, "Ø12.00");
named.insert(at["record"]["kind"]["radial"]["edge"]["name"].as_str().unwrap().to_string());
}
assert_eq!(named.len(), 2, "{named:?}");
assert!(refused(&mut s, json!({"view": ids::encode(view), "kind": {"radial": {"edge": rim(7.0, 20.0)}}})).contains("no edge"));
let (sin, cos) = 45f64.to_radians().sin_cos();
reads(&add(&mut s, view, json!({"radial": {"edge": [75.0 + 5.0 * cos, 0.0, 25.0 + 5.0 * sin]}})), 5.0, "R5.00");
reads(&add(&mut s, view, json!({"length": {"edge": [74.99, 0, 30]}})), 75.0, "75.00");
}
/// A linear dimension measures from the centre of a hole or an arc, each named by its edge: the
/// hole through the box about (40, 15) on the front, and the fillet's quarter arc about (75, 25).
/// A straight edge has no centre, and a centre's edge has no path of its own.
#[test]
fn a_linear_dimension_measures_from_a_holes_centre() {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
run(&mut s, "drawing.close", json!({}));
run(&mut s, "solid.cylinder", json!({"radius": 6, "height": 42, "base": [40, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
run(&mut s, "solid.fillet", json!({"edges": [[80, 20, 30]], "radius": 5, "body": "Box"}));
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
let centre = |at: [f64; 3], radius: f64| json!({"centre": {"body": "Box", "index": round_index(&s, at, radius)}});
let (hole, arc) = (centre([40.0, 0.0, 15.0], 6.0), centre([75.0, 0.0, 25.0], 5.0));
let along = add(&mut s, view, json!({"linear": {"a": [0, 0, 0], "b": hole, "direction": "horizontal"}}));
reads(&along, 40.0, "40.00");
let saved = &along["record"]["kind"]["linear"]["b"];
assert_eq!((saved["centre"].clone(), saved["edges"].as_array().map(Vec::len)), (json!(true), Some(1)), "{saved}");
reads(&add(&mut s, view, json!({"linear": {"a": [0, 0, 0], "b": hole, "direction": "vertical"}})), 15.0, "15.00");
reads(&add(&mut s, view, json!({"linear": {"a": hole, "b": arc, "direction": "horizontal"}})), 35.0, "35.00");
reads(&add(&mut s, view, json!({"linear": {"a": hole, "b": arc, "direction": "vertical"}})), 10.0, "10.00");
let before = s.drawing("test").unwrap().to_json().unwrap();
let v = ids::encode(view);
let linear = |a: Value| json!({"view": v, "kind": {"linear": {"a": a, "b": [80, 0, 0]}}});
let cases = [
(linear(json!({"centre": [40, 0, 30]})), "isn't a circle or an arc: a centre is a circle's or an arc's"),
(linear(json!({"centre": {"point": [40, 0, 30], "path": [1]}})), "{centre: a circle or an arc}"),
(linear(json!({"centre": [40, 0, 30], "edges": [[40, 0, 30], [0, 0, 15]]})), "{centre: a circle or an arc}"),
(linear(json!({"centre": [40, 20, 15]})), "no edge at [40, 20, 15]"),
];
for (p, why) in cases {
let e = refused(&mut s, p.clone());
assert!(e.contains(why), "{p}: {e}");
}
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
}
/// A chain from the box's bottom front corner through the centres of holes at 10, 30 and 60 along
/// it reads 10, 20 and 30, adding up to its overall 60 within 1e-9, and shows its members' numbers,
/// not one of its own. Its direction is its one setting. It keeps its rules: its points in order
/// along its line, 3 to 64 of them (counted before any is looked up), and no text instead.
#[test]
fn a_chain_reads_holes_at_10_30_and_60_as_10_20_and_30() {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
run(&mut s, "drawing.close", json!({}));
for x in [10, 30, 60] {
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 42, "base": [x, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
}
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
let hole = |x: f64| json!({"centre": {"body": "Box", "index": round_index(&s, [x, 0.0, 15.0], 3.0)}});
let (h10, h30, h60) = (hole(10.0), hole(30.0), hole(60.0));
let steps = s.undo.len();
let chain = add(&mut s, view, json!({"chain": {"points": [[0, 0, 0], h10, h30, h60], "direction": "horizontal"}}));
assert_eq!(s.undo.len(), steps + 1, "one undo step");
assert_eq!((chain["status"].as_str(), chain.get("text")), (Some("ok"), None), "{chain}");
let members = |reply: &Value| {
let listed = reply["members"].as_array().cloned().unwrap_or_default();
listed.iter().map(|m| (m["value"].as_f64().unwrap_or(f64::NAN), m["text"].as_str().unwrap_or("").to_string())).collect::<Vec<_>>()
};
let read = members(&chain);
assert_eq!(read.iter().map(|(_, text)| text.as_str()).collect::<Vec<_>>(), ["10.00", "20.00", "30.00"]);
assert!(read.iter().zip([10.0, 20.0, 30.0]).all(|((value, _), part)| (value - part).abs() < 1e-9), "{read:?}");
let overall = chain["value"].as_f64().unwrap();
assert!((overall - 60.0).abs() < 1e-9 && (read.iter().map(|(value, _)| value).sum::<f64>() - overall).abs() < 1e-9, "{chain}");
assert_eq!(chain["record"]["kind"]["chain"]["points"].as_array().map(Vec::len), Some(4));
// A direction given against the chain's way reads the same.
let id = chain["dimension"].clone();
assert_eq!(edit(&mut s, &id, json!({"direction": {"along": [-1, 0]}}))["changed"], json!(["direction"]));
assert_eq!(members(&run(&mut s, INSPECT, json!({"dimension": id}))), read);
let before = s.drawing("test").unwrap().to_json().unwrap();
for (set, why) in [
(json!({"reading": "true"}), "a chain has only `direction`"),
(json!({"other_side": true}), "a chain has only `direction`"),
(json!({"diameter": true}), "`diameter` is a radial dimension's"),
(json!({"override_text": "60"}), "a chain shows its members' numbers, never a text instead"),
] {
let p = json!({"dimension": id, "set": set});
let e = s.execute(EDIT, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
assert!(e.contains(why), "{p}: {e}");
}
let v = ids::encode(view);
let chained = |points: Value| json!({"view": v, "kind": {"chain": {"points": points, "direction": "horizontal"}}});
let nowhere = json!([1e6, 1e6, 1e6]);
let cases = [
(chained(json!([h10, h60, h30])), "a chain's points run one way along its line"),
(chained(json!([h10, h60])), "a chain has 3 to 64 points"),
(chained(Value::Array(vec![nowhere.clone(); 65])), "a chain has 3 to 64 points"),
(chained(json!([h10, nowhere, h60])), "no corner at"),
(json!({"view": v, "kind": {"chain": {"points": [h10, h30, h60], "reading": "true"}}}), "unknown field `reading`"),
(json!({"view": v, "kind": {"chain": {"points": [h10, h30, h60]}}, "override_text": "60"}), "never a text instead"),
];
for (p, why) in cases {
let e = refused(&mut s, p.clone());
assert!(e.contains(why), "{p}: {e}");
}
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
}
/// A cylinder's diameter is read between its silhouettes: a boss of radius 5 along X on the box's
/// right face, seen side on from the front, reads Ø10.00, picked by a point on it or by its body
/// and index, and saved by its face's name; it takes no settings. A flat face, a fillet's quarter
/// round (one of its silhouettes isn't on it) and a hole the view sees end-on are refused, with
/// the drawing unchanged.
#[test]
fn a_cylinders_diameter_is_read_between_its_silhouettes() {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
run(&mut s, "drawing.close", json!({}));
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [80, 20, 15], "axis": "X", "operation": "join", "targets": ["Box"]}));
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 30]], "radius": 4, "body": "Box"}));
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 42, "base": [20, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
// 30° round from the boss's top, away from where its two half faces meet.
let on_boss = [90.0, 22.5, 15.0 + 5.0 * 30f64.to_radians().cos()];
let boss = add(&mut s, view, json!({"cylinder": {"face": on_boss}}));
reads(&boss, 10.0, "Ø10.00");
assert_eq!((boss["exact"].clone(), boss["foreshortened"].clone()), (json!(true), json!(false)), "{boss}");
let saved = boss["record"]["kind"]["cylinder"]["face"].clone();
assert!(saved["body"] == "Box" && saved["name"].as_str().is_some_and(|n| !n.is_empty()), "{saved}");
let index = {
let state = s.model.state();
solidcraft_doc::appearance::face_index_at(state.body("Box").unwrap(), Vec3::from(on_boss)).unwrap()
};
let by_index = add(&mut s, view, json!({"cylinder": {"face": {"body": "Box", "index": index}}}));
assert_eq!(by_index["record"]["kind"], boss["record"]["kind"]);
let id = boss["dimension"].clone();
let before = s.drawing("test").unwrap().to_json().unwrap();
for (set, why) in [
(json!({"direction": "horizontal"}), "a cylinder's diameter has no direction, reading or side"),
(json!({"reading": "true"}), "a cylinder's diameter has no direction, reading or side"),
(json!({"diameter": false}), "`diameter` is a radial dimension's"),
] {
let p = json!({"dimension": id, "set": set});
let e = s.execute(EDIT, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
assert!(e.contains(why), "{p}: {e}");
}
let v = ids::encode(view);
let cylinder = |face: Value| json!({"view": v, "kind": {"cylinder": {"face": face}}});
let on_fillet = [40.0, 4.0 - 4.0 * 45f64.to_radians().cos(), 26.0 + 4.0 * 45f64.to_radians().sin()];
let cases = [
(cylinder(json!([40, 0, 10])), "isn't a cylinder"),
(cylinder(json!(on_fillet)), "isn't a whole cylinder in this view"),
(cylinder(json!([23, 20, 15])), "the cylinder is seen end-on in this view"),
(cylinder(json!([200, 200, 200])), "no face at [200, 200, 200]"),
(cylinder(json!({"body": "Box", "index": 9999})), "body `Box` has no face 9999"),
(cylinder(json!({"body": "Box"})), "a face is a point on it"),
(cylinder(json!({"body": 7, "index": 0})), "a face's `body` is its name"),
];
for (p, why) in cases {
let e = refused(&mut s, p.clone());
assert!(e.contains(why), "{p}: {e}");
}
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
}
/// Two bosses along X on the box's right face, radius 5 at z = 15 and radius 3 at z = 25, in the
/// front view: near to near 2, near to far 8, far to near 12 and far to far 18, saved by each
/// face's name and side; up the view the same. Two faces of one boss make its Ø10, which takes no
/// direction. Its edit takes a direction only. Refused, with the drawing unchanged: a flat face, a
/// fillet's quarter round on the side its silhouette isn't on, a hole seen end-on, and a side that
/// is neither near nor far.
#[test]
fn two_cylinders_read_between_the_silhouettes_given() {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
run(&mut s, "drawing.close", json!({}));
for (radius, z) in [(5, 15), (3, 25)] {
let boss = json!({"radius": radius, "height": 20, "base": [80, 20, z], "axis": "X", "operation": "join", "targets": ["Box"]});
run(&mut s, "solid.cylinder", boss);
}
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 30]], "radius": 4, "body": "Box"}));
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 42, "base": [20, -1, 15], "axis": "Y", "operation": "cut", "targets": ["Box"]}));
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
// Each 30° round from its boss's top, away from where its two half faces meet.
let on = |r: f64, z: f64| json!([90.0, 20.0 + r * 30f64.to_radians().sin(), z + r * 30f64.to_radians().cos()]);
let (big, small) = (on(5.0, 15.0), on(3.0, 25.0));
let between = |a: &str, b: &str| json!({"silhouettes": {"a": {"face": big, "side": a}, "b": {"face": small, "side": b}}});
for (a, b, value) in [("near", "near", 2.0), ("near", "far", 8.0), ("far", "near", 12.0), ("far", "far", 18.0)] {
let added = add(&mut s, view, between(a, b));
reads(&added, value, &format!("{value:.2}"));
assert_eq!((added["exact"].clone(), added["foreshortened"].clone()), (json!(true), json!(false)), "{added}");
let saved = &added["record"]["kind"]["silhouettes"];
assert!(saved["a"]["side"] == a && saved["b"]["side"] == b && saved["direction"] == "aligned", "{saved}");
assert_eq!(saved["a"]["face"]["body"], "Box", "{saved}");
let up = edit(&mut s, &added["dimension"], json!({"direction": "vertical"}));
assert_eq!(up["changed"], json!(["direction"]), "{up}");
reads(&run(&mut s, INSPECT, json!({"dimension": added["dimension"]})), value, &format!("{value:.2}"));
}
let other_half = on(-5.0, 15.0);
let one = add(&mut s, view, json!({"silhouettes": {"a": {"face": big, "side": "near"}, "b": {"face": other_half, "side": "far"}}}));
reads(&one, 10.0, "Ø10.00");
assert!(one["record"]["kind"]["cylinder"]["face"]["name"].is_string(), "{one}");
let id = add(&mut s, view, between("near", "near"))["dimension"].clone();
let before = s.drawing("test").unwrap().to_json().unwrap();
let e = s.execute(EDIT, &json!({"dimension": id, "set": {"reading": "true"}})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("only `direction`"), "{e}");
let v = ids::encode(view);
let with =
|b: Value, side: &str| json!({"view": v, "kind": {"silhouettes": {"a": {"face": big, "side": "near"}, "b": {"face": b, "side": side}}}});
let on_fillet = [40.0, 4.0 - 4.0 * 45f64.to_radians().cos(), 26.0 + 4.0 * 45f64.to_radians().sin()];
let halves = json!({"view": v, "kind": {"silhouettes": {"a": {"face": big, "side": "near"}, "b": {"face": other_half, "side": "far"},
"direction": "vertical"}}});
let cases = [
(halves, "takes no direction"),
(with(json!([40, 0, 10]), "near"), "isn't a cylinder"),
(with(json!(on_fillet), "near"), "near silhouette in this view isn't on it"),
(with(json!([23, 20, 15]), "near"), "the cylinder is seen end-on in this view"),
(with(small.clone(), "left"), "unknown variant `left`"),
];
for (p, why) in cases {
let e = refused(&mut s, p.clone());
assert!(e.contains(why), "{p}: {e}");
}
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
}
/// A dimension whose edge is filleted away has no value, and gives the one it last read this
/// session as its last good value.
#[test]
fn a_dimension_that_cant_be_measured_gives_its_last_good_value() {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
let added = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}));
reads(&added, 80.0, "80.00");
assert!(added.get("last_good").is_none(), "measured now: {added}");
run(&mut s, "drawing.close", json!({}));
run(&mut s, "solid.fillet", json!({"body": "Box", "radius": 5, "edges": [[40, 0, 30]]}));
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
let read = run(&mut s, INSPECT, json!({"dimension": added["dimension"]}));
assert!(read.get("value").is_none() && read["status"] != json!("ok"), "{read}");
assert_eq!(read["last_good"], json!(80.0), "{read}");
// A read isn't in the document: a session on it, with no file in between, has none.
let mut fresh = Session::new((*s.doc).clone());
run(&mut fresh, "drawing.open", json!({"drawing": ids::encode(drawing)}));
assert!(run(&mut fresh, INSPECT, json!({"dimension": added["dimension"]})).get("last_good").is_none());
}
/// A setting its kind hasn't got, and a value the record would refuse, are refused with the
/// drawing unchanged; an angle takes its other side.
#[test]
fn an_edit_refuses_what_the_dimension_cant_take() {
let (mut s, view) = front_view();
let length = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}))["dimension"].clone();
let angle = add(&mut s, view, json!({"angle": {"a": [40, 0, 30], "b": [0, 0, 15]}}))["dimension"].clone();
let before = s.drawing("test").unwrap().to_json().unwrap();
let nothing = json!(ids::encode(99));
let cases = [
(&length, json!({"other_side": true}), "`other_side` is an angle's"),
(&angle, json!({"direction": "horizontal"}), "an angle has no `direction`"),
(&length, json!({"diameter": true}), "`diameter` is a radial dimension's"),
(&length, json!({"decimals": 7}), "at most 6 decimals"),
(&length, json!({"offset_mm": 1e9}), "on paper"),
(&length, json!({"override_text": ""}), "instead of its number is 1 to"),
(&length, json!({"offset_mm": null}), ""),
(&length, json!({"edge": [0, 0, 0]}), "edge"),
(&nothing, json!({"decimals": 1}), "the drawing has no dimension"),
];
for (id, set, why) in cases {
let p = json!({"dimension": id, "set": set});
let e = s.execute(EDIT, &p).err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted: {p}"));
assert!(e.contains(why), "{p}: {e}");
}
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
assert_eq!(edit(&mut s, &angle, json!({"other_side": true}))["changed"], json!(["other_side"]));
assert_eq!(run(&mut s, INSPECT, json!({"dimension": angle}))["record"]["kind"]["angle"]["other_side"], json!(true));
}
/// A pick that doesn't name one thing, and a dimension the record would refuse, are refused
/// with the drawing unchanged.
#[test]
fn a_pick_that_names_nothing_or_several_things_is_refused() {
let (mut s, view) = front_view();
let before = s.drawing("test").unwrap().to_json().unwrap();
let v = ids::encode(view);
let length = |edge: Value| json!({"view": v, "kind": {"length": {"edge": edge}}});
let linear = |a: Value| json!({"view": v, "kind": {"linear": {"a": a, "b": [80, 0, 0]}}});
let cases = [
(length(json!([40, 20, 15])), "no edge at [40, 20, 15]"),
(length(json!([0, 0, 0])), "[0, 0, 0] is on 3 edges"),
(length(json!({"body": "Nope", "index": 0})), "no body `Nope`"),
(length(json!({"body": "Box", "index": 999})), "body `Box` has no edge 999"),
(length(json!({"point": [40, 0, 30], "index": 1})), "an edge is a point on it"),
(linear(json!([40, 0, 0])), "no corner at [40, 0, 0]"),
(linear(json!({"edges": [{"point": [40, 0, 0]}, {"point": [40, 40, 30]}]})), "the corner's edges don't meet"),
(linear(json!({"edges": [{"point": [40, 0, 0], "path": [1]}]})), "each without a path of its own"),
(json!({"view": v, "kind": {"length": {"edge": [40, 0, 30]}}, "decimals": 7}), "at most 6 decimals"),
(json!({"view": v, "kind": {"length": {"edge": [40, 0, 30]}}, "offset_mm": 1e9}), "on paper"),
(json!({"view": ids::encode(99), "kind": {"length": {"edge": [40, 0, 30]}}}), "the drawing has no view"),
(json!({"view": v, "kind": {"radius": {"edge": [40, 0, 30]}}}), "radius"),
(json!({"view": v, "kind": {"radial": {"edge": [40, 0, 30]}}}), "isn't a circle or an arc"),
(json!({"view": v, "kind": {"radial": {"edge": [0, 20, 0]}}}), "isn't a circle in this view (it isn't parallel to it)"),
(json!({"view": v, "kind": {"radial": {"edge": [40, 0, 30], "diameter": 1}}}), "must be true or false"),
];
for (p, why) in cases {
let e = refused(&mut s, p.clone());
assert!(e.contains(why), "{p}: {e}");
}
assert_eq!(s.drawing("test").unwrap().to_json().unwrap(), before);
let e = s.execute(INSPECT, &json!({"dimension": ids::encode(99)})).err().map(|e| e.to_string()).unwrap_or_default();
assert!(e.contains("the drawing has no dimension"), "{e}");
}
/// Every place a value can go in `v`, as JSON pointers: `v` itself, then each member and item.
fn places(v: &Value, at: String, out: &mut Vec<String>) {
out.push(at.clone());
match v {
Value::Object(o) => o.iter().for_each(|(k, x)| places(x, format!("{at}/{k}"), out)),
Value::Array(l) => l.iter().enumerate().for_each(|(i, x)| places(x, format!("{at}/{i}"), out)),
_ => {}
}
}
/// A hostile value in each place of each command's parameters, at every depth (picks, paths,
/// corners' edges, a chain's points, the edit's settings): never a panic, and the drawing keeps its
/// rules.
#[test]
fn hostile_dimension_params_never_panic() {
let (mut s, view) = front_view();
let id = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}))["dimension"].clone();
// Aligned from the first corner to the last, each corner past the one before.
let chain = add(&mut s, view, json!({"chain": {"points": [[0, 0, 0], [80, 0, 0], [80, 0, 30]]}}))["dimension"].clone();
let v = json!(ids::encode(view));
let corner = json!({"edges": [{"body": "Box", "index": 0}, {"point": [0, 0, 15]}], "path": [1]});
let calls = [
(
ADD,
json!({"view": v, "kind": {"linear": {"a": [0, 0, 0], "b": corner, "direction": {"along": [1, 0]}, "reading": "projected"}},
"offset_mm": 5, "text": {"at": [1, 2]}, "decimals": 1, "override_text": "x"}),
),
(ADD, json!({"view": v, "kind": {"angle": {"a": {"point": [40, 0, 30], "path": [1]}, "b": [0, 0, 15], "other_side": true}}})),
(ADD, json!({"view": v, "kind": {"radial": {"edge": {"body": "Box", "index": 0, "path": [1]}, "diameter": true}}})),
(
ADD,
json!({"view": v, "kind": {"chain": {"points": [[0, 0, 0], {"centre": {"body": "Box", "index": 0}, "path": [1]}, corner, [80, 0, 0]],
"direction": "horizontal"}}}),
),
(EDIT, json!({"dimension": chain, "set": {"direction": {"along": [1, 1]}, "decimals": 3}})),
(ADD, json!({"view": v, "kind": {"cylinder": {"face": {"body": "Box", "index": 0, "path": [1]}}}})),
(
ADD,
json!({"view": v, "kind": {"silhouettes": {"a": {"face": {"body": "Box", "index": 0, "path": [1]}, "side": "near"},
"b": {"face": [0, 0, 15], "side": "far"}, "direction": {"along": [1, 1]}}}}),
),
(
EDIT,
json!({"dimension": id, "set": {"offset_mm": 5, "text": "centred", "decimals": 1, "override_text": null, "direction": "vertical",
"reading": "projected"}}),
),
(INSPECT, json!({"dimension": id})),
];
let many = |v: Value| Value::Array(vec![v; 1000]);
let mut values = crate::tests::hostile_values();
values.extend([
json!({"body": "Box", "index": u64::MAX}),
json!({"edges": []}),
json!({"edges": many(json!([0, 0, 0]))}),
many(json!(1)),
json!([1e308, 0, 0]),
json!("x".repeat(600)),
]);
let mut internal = Vec::new();
for (cmd, template) in calls {
let mut at = Vec::new();
places(&template, String::new(), &mut at);
for (place, value) in at.iter().flat_map(|place| values.iter().map(move |value| (place, value))) {
let mut p = template.clone();
if let Some(slot) = p.pointer_mut(place) {
*slot = value.clone();
}
let mut copy = s.scratch();
if let Err(crate::EngineError::Internal(_, msg)) = copy.execute(cmd, &p) {
internal.push(format!("{cmd} {p}: {msg}"));
}
copy.doc.drawings.iter().for_each(|d| d.validate().unwrap());
}
}
assert!(internal.is_empty(), "dimension commands panicked:\n{}", internal.join("\n"));
}
/// A dimension on a reference part's edge: added by its occurrence, reading its length, and the same
/// after the part stops being reference. The finder doesn't ask what a part's type is.
#[test]
fn a_dimension_can_measure_a_reference_parts_edge() {
let mut s = Session::default();
let block = run(&mut s, "component.create", json!({"name": "Block"}))["occurrence"].clone();
run(&mut s, "solid.box", json!({"length": 20, "width": 30, "height": 10, "body_name": "Block", "operation": "new"}));
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.metadata.set", json!({"component": "Block", "bom_type": "reference"}));
let (mut s, view) = drawn_from_the_front(s);
let edge = json!({"point": [10, 0, 10], "path": [block]});
let added = run(&mut s, ADD, json!({"view": ids::encode(view), "kind": {"length": {"edge": edge}}}));
assert!((added["value"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{added}");
let drawing = ids::encode(s.drawing("test").unwrap().id);
run(&mut s, "drawing.close", json!({}));
run(&mut s, "component.metadata.set", json!({"component": "Block", "bom_type": "normal"}));
run(&mut s, "drawing.open", json!({"drawing": drawing}));
let read = run(&mut s, INSPECT, json!({"dimension": added["dimension"]}));
assert_eq!((&read["status"], &read["value"]), (&json!("ok"), &added["value"]), "{read}");
}
/// A box with an 80 mm dimension on its top front edge, read once, and the drawing closed: the
/// session, the drawing's id and the dimension's.
fn read_once() -> (Session, u64, u64) {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
let added = add(&mut s, view, json!({"length": {"edge": [40, 0, 30]}}));
run(&mut s, "drawing.close", json!({}));
(s, drawing, ids::decode(added["dimension"].as_str().unwrap()).unwrap())
}
/// Fillets away the edge the [`read_once`] dimension measures.
fn lose_the_edge(s: &mut Session) {
run(s, "solid.fillet", json!({"body": "Box", "radius": 5, "edges": [[40, 0, 30]]}));
}
/// `s` saved to a file called `name` and opened from it in a new session, with `drawing` open.
fn reopened(s: &mut Session, drawing: u64, name: &str) -> Session {
let dir = std::env::temp_dir().join(format!("solidcraft-last-good-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
run(s, "file.save_as", json!({"path": path}));
let mut fresh = Session::default();
run(&mut fresh, "doc.open", json!({"path": path}));
run(&mut fresh, "drawing.open", json!({"drawing": ids::encode(drawing)}));
let _ = std::fs::remove_dir_all(&dir);
fresh
}
fn inspect_of(s: &mut Session, dimension: u64) -> Value {
run(s, INSPECT, json!({"dimension": ids::encode(dimension)}))
}
/// A lost dimension still says what it last read after the design is saved and opened again, in
/// either form of the file. It stays lost, with no value and no text, and a read of it leaves
/// nothing to save.
#[test]
fn a_lost_dimension_keeps_its_last_good_value_after_reopening() {
for name in [format!("part.{}", solidcraft_io::DESIGN_EXT), "part.json".to_string()] {
let (mut s, drawing, dimension) = read_once();
lose_the_edge(&mut s);
let mut opened = reopened(&mut s, drawing, &name);
let read = inspect_of(&mut opened, dimension);
assert_eq!(read["status"], json!("lost"), "{name}: {read}");
assert!(read.get("value").is_none() && read.get("text").is_none(), "{name}, never its value: {read}");
assert_eq!(read["last_good"], json!(80.0), "{name}: {read}");
assert!(!opened.is_dirty(), "{name}: a read leaves nothing to save");
}
}
/// A read changes nothing in the document: no undo step, nothing to save, the same document. Only
/// the file's copy has the value, and a session that read nothing copies nothing.
#[test]
fn a_read_stays_out_of_the_document() {
let (mut s, drawing, dimension) = read_once();
run(&mut s, "drawing.open", json!({"drawing": ids::encode(drawing)}));
let path = std::env::temp_dir().join(format!("solidcraft-last-good-read-{}.json", std::process::id()));
run(&mut s, "file.save_as", json!({"path": path}));
let (doc, steps) = (s.doc.clone(), s.undo.len());
inspect_of(&mut s, dimension);
assert!(Arc::ptr_eq(&s.doc, &doc) && s.undo.len() == steps && !s.is_dirty());
assert_eq!(s.doc.drawing(drawing).unwrap().dimension(dimension).unwrap().last_good, None, "only in the file's copy");
assert_eq!(s.design().drawing(drawing).unwrap().dimension(dimension).unwrap().last_good, Some(80.0));
let opened = Session::new(solidcraft_io::read_design(&std::fs::read(&path).unwrap()).unwrap());
let _ = std::fs::remove_file(&path);
assert!(matches!(opened.design(), std::borrow::Cow::Borrowed(_)), "nothing read: nothing to copy");
assert_eq!(opened.doc.drawing(drawing).unwrap().dimension(dimension).unwrap().last_good, Some(80.0));
let bytes = solidcraft_io::write_design(&opened.design());
assert_eq!(solidcraft_io::write_design(&solidcraft_io::read_design(&bytes).unwrap()), bytes, "save, open, save: the same bytes");
}
/// A saved value is only ever what it last read: measured now, a dimension reads the model and has
/// no last good value, whatever the file said. A newer read replaces the saved one, in the session
/// and through the next save.
#[test]
fn a_saved_last_good_value_never_reads_as_the_value() {
let (mut s, drawing, dimension) = read_once();
s.dimension_values.insert((drawing, dimension), 55.5);
let mut opened = reopened(&mut s, drawing, "seeded.json");
let read = inspect_of(&mut opened, dimension);
reads(&read, 80.0, "80.00");
assert_eq!(read["record"]["last_good"], json!(55.5), "as saved");
assert!(read.get("last_good").is_none(), "measured now: {read}");
run(&mut opened, "drawing.close", json!({}));
lose_the_edge(&mut opened);
run(&mut opened, "drawing.open", json!({"drawing": ids::encode(drawing)}));
assert_eq!(inspect_of(&mut opened, dimension)["last_good"], json!(80.0), "this session's read comes first");
run(&mut opened, "drawing.close", json!({}));
let mut again = reopened(&mut opened, drawing, "again.json");
assert_eq!(inspect_of(&mut again, dimension)["last_good"], json!(80.0), "and replaces the saved one");
}
/// An angle saves its last good value in radians and gives it in degrees, as it gives its value.
#[test]
fn a_lost_angle_gives_its_saved_value_in_degrees() {
let (mut s, view) = front_view();
let drawing = s.drawing("test").unwrap().id;
let added = add(&mut s, view, json!({"angle": {"a": [40, 0, 30], "b": [0, 0, 15]}}));
let dimension = ids::decode(added["dimension"].as_str().unwrap()).unwrap();
run(&mut s, "drawing.close", json!({}));
lose_the_edge(&mut s);
let mut opened = reopened(&mut s, drawing, "angle.json");
let read = inspect_of(&mut opened, dimension);
assert_eq!(read["status"], json!("lost"), "{read}");
assert!((read["record"]["last_good"].as_f64().unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "{read}");
assert!((read["last_good"].as_f64().unwrap() - 90.0).abs() < 1e-9, "{read}");
}
/// A model can read past the largest number a drawing saves: that value isn't saved, so the file
/// still opens, and the dimension has none.
#[test]
fn a_value_past_what_a_drawing_saves_leaves_the_file_able_to_open() {
let (mut s, drawing, dimension) = read_once();
s.dimension_values.insert((drawing, dimension), 2e9);
let mut opened = reopened(&mut s, drawing, "huge.json");
assert!(inspect_of(&mut opened, dimension)["record"].get("last_good").is_none());
}
/// A background save runs on a scratch copy of the session, and an autosave on its own snapshot:
/// both keep what the dimensions last read, so a recovered design has it too.
#[test]
fn background_saves_and_autosaves_keep_the_last_good_value() {
let (s, drawing, dimension) = read_once();
let last_good = |doc: &solidcraft_doc::Document| doc.drawing(drawing).unwrap().dimension(dimension).unwrap().last_good;
assert_eq!(last_good(&s.scratch().design()), Some(80.0));
let dir = std::env::temp_dir().join(format!("solidcraft-last-good-autosave-{}", std::process::id()));
let mut saver = crate::recovery::Autosaver::new(&dir, std::time::Duration::from_secs(3600)).unwrap();
assert!(saver.save(&s).unwrap());
drop(saver);
let found = crate::recovery::orphans(&dir);
let (doc, _) = crate::recovery::load(&dir, &found[0].id).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(last_good(&doc), Some(80.0));
}
/// What this session read goes with this design: opening another one starts with none, so it can't
/// reach that design's file, even a copy with the same ids.
#[test]
fn opening_a_design_starts_with_no_last_good_values() {
let (mut s, _, _) = read_once();
let path = std::env::temp_dir().join(format!("solidcraft-last-good-open-{}.json", std::process::id()));
std::fs::write(&path, solidcraft_io::write_design(&s.doc)).unwrap();
run(&mut s, "doc.open", json!({"path": path}));
let _ = std::fs::remove_file(&path);
assert!(s.dimension_values.is_empty());
}
/// A view copied to a sheet brings its dimensions with what they saved: the same references, read
/// on the same view direction.
#[test]
fn a_copied_dimension_keeps_its_saved_value() {
let (mut s, drawing, dimension) = read_once();
let mut opened = reopened(&mut s, drawing, "copy.json");
let view = opened.drawing("test").unwrap().dimension(dimension).unwrap().view;
run(&mut opened, "drawing.view.copy_to_sheet", json!({"view": ids::encode(view), "sheet": "Sheet 1", "position_mm": [100, 100]}));
let d = opened.drawing("test").unwrap();
let copy = d.dimensions.iter().find(|x| x.id != dimension).unwrap();
assert_eq!(copy.last_good, Some(80.0));
}
+90
View File
@@ -0,0 +1,90 @@
//! `drawing.export` (#4890 A7): one sheet of the open drawing written out from its scene
//! ([`crate::drawing_scene::build`]), the scene the sheet page draws, so a file shows what the page
//! does. SVG for now: PNG comes with the sheet PNG writer (#4890 A4) and DXF with the shared DXF
//! writer (#5319). A sheet with a view that failed isn't written; the refusal names the views.
use std::path::Path;
use serde_json::Value;
use solidcraft_doc::drawing::DEFAULT_DPI;
use solidcraft_doc::ids;
use solidcraft_geom::paper::NoPictures;
use super::CommandSpec;
use crate::drawing_scene::{Status, sheet_id};
use crate::params::bad;
use crate::schema::drawing_export::{ExportFormat, ExportParams, ExportReport, ExportResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const CMD: &str = "drawing.export";
const CONTRACT: TypedContract<ExportParams> = TypedContract::new();
/// The longest path taken, in bytes, as the view DXF's.
const MAX_PATH_BYTES: usize = 4096;
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Sheet", crate::checked!("drawing.export", export))
.icon("export")
.in_drawing()
.noundo()
.doc_only()
.contract(&CONTRACT)
.output(output_schema_for::<ExportResult>)
.params("sheet: id or name; format?: svg (default; png and dxf are not supported yet); path?: .svg file (else the reply holds the SVG text) → {sheet, format, bytes, path | svg, report: {widths_snapped, pieces_merged}, views: [{view, status: drawn|placeholder|failed, reason?, stats?}]}. The sheet as drawing.scene draws it: its drawing border, then each view's visible lines. Refused, naming them, while any view on the sheet failed; a placeholder view draws nothing. No model is evaluated.")];
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let request: ExportParams = crate::schema::read(CMD, p)?;
match request.format {
ExportFormat::Svg => {}
ExportFormat::Png => return Err(bad(CMD, "not supported yet: PNG needs the sheet PNG writer (#4890 A4)")),
ExportFormat::Dxf => return Err(bad(CMD, "not supported yet: DXF needs the shared DXF writer (#5319)")),
}
if let Some(path) = &request.path {
let svg = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("svg"));
if path.len() > MAX_PATH_BYTES || !svg {
return Err(bad(CMD, "path must be a .svg file"));
}
solidcraft_io::vfs::check(path).map_err(|e| EngineError::Other(e.to_string()))?;
}
let d = s.drawing(CMD)?.clone();
let sheet = sheet_id(&d, &request.sheet, CMD)?;
let built = crate::drawing_scene::build(s, &d, sheet)?;
let failed: Vec<String> = built
.views
.iter()
.filter_map(|v| match &v.status {
Status::Failed(why) => Some(format!("{} ({why})", ids::encode(v.view))),
_ => None,
})
.collect();
if !failed.is_empty() {
return Err(bad(CMD, format!("not written: views on the sheet failed: {}", failed.join(", "))));
}
let prepared = built.scene.prepare().map_err(|e| bad(CMD, e.to_string()))?;
// A drawing's scene holds no pictures yet, so their resolution goes unused until shaded views bring the sheet's own.
let (svg, written) = solidcraft_io::paper::paper_svg(&prepared, &NoPictures, DEFAULT_DPI).map_err(|e| bad(CMD, e.to_string()))?;
let bytes = svg.len();
let (path, svg) = match request.path {
Some(path) => {
// Checked again just before writing, as the view DXF does.
solidcraft_io::vfs::check(&path).map_err(|e| EngineError::Other(e.to_string()))?;
solidcraft_io::write_atomic(Path::new(&path), &svg, false).map_err(|e| EngineError::Other(e.to_string()))?;
(Some(path), None)
}
None => (None, Some(String::from_utf8(svg).map_err(|e| EngineError::Other(e.to_string()))?)),
};
let reply = ExportResult {
sheet: ids::encode(sheet),
format: request.format,
bytes,
path,
svg,
report: ExportReport { widths_snapped: written.widths_snapped, pieces_merged: written.pieces_merged },
views: built.views.into_iter().map(Into::into).collect(),
param_warnings: Vec::new(),
};
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
}
#[cfg(test)]
#[path = "drawing_export_tests.rs"]
mod tests;

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