Compare commits

...

33 Commits

Author SHA1 Message Date
lillith 80e5bb3470 Drawing: reuse bounded 2D sketch tools on sheets (#7082) 2026-10-28 16:56:29 +01:00
lillith d3b7cfcf6f Drawing: support paper-driven sheet dimensions and source expressions (#7082) 2026-10-28 16:02:41 +01:00
lillith 81aa1804db Drawing: route point moves and palette options to sheet sketches (#7082) 2026-10-28 14:38:16 +01:00
lillith 8b1bf7b28d feat(drawings): expose read-only sheet chain and solve queries (#7082) 2026-10-28 13:11:07 +01:00
lillith c338eefc27 Drawing: route Coincident through the sheet sketch receiver (W38a) 2026-10-28 12:44:30 +01:00
lillith 9eccea7ca8 Drawing: add sheet reference dimensions without model edits (W38a) 2026-10-28 11:32:32 +01:00
lillith c474325189 Drawing: route construction and deletion through sheet sketches (W38a) 2026-10-28 08:35:35 +01:00
lillith 0e897e670b Drawing: qualify fix and symmetry sheet constraints (#7082) 2026-10-28 07:37:45 +01:00
lillith 24ad198407 Drawing: qualify tangent and collinear sheet constraints (#7082) 2026-10-28 06:39:33 +01:00
lillith 71424dd38b Drawing: qualify six geometric sheet constraints (#7082) 2026-10-28 05:25:22 +01:00
lillith d5bd3b94fc Drawing: reuse polygon and slot tools on paper sketches (#7082) 2026-10-27 22:24:51 +01:00
lillith 03234e382c Drawing: extend sheet geometry through shared tools (#7082) 2026-10-27 21:01:42 +01:00
lillith 791d55f16c Drawing: publish sheet edits through the session boundary (#7082)
Address CamWight review 5669052056: explicit paper frame, staged Drawing edits, uniform save validation and preserved cumulative bounds. Keep the existing store and Session as the publication/history owner. Full workspace and GUI acceptance remain pending.
2026-10-27 19:06:51 +01:00
lillith 375d750096 Drawing: edit sheet sketches through shared commands (#7082)
Add an explicitly owned sheet entry, typed create/edit/inspect/cancel commands,
and reuse the existing rectangle/line/point implementations through the W38.0
store and R2 atomic solver boundary. Preserve source parameters and model active
state; restore the sheet entry through drawing undo/redo and checkpoints.

Eight new command tests pass. Across the selected original and new engine groups:
479 unique passed, zero failed, two pre-existing ignored. Native engine check,
strict Clippy and wasm pass. Source-hashed CI-profile artifact reused for all
runtime checks. Full repository and native/browser UI acceptance remain pending;
this is a command subset, not all sketch tools or complete W38a.

No kernel, format, dependency, shared catalog or CI changes. The pending locale
delta needs main's existing #6448 loader on the drawings receiver. Original Cam
store tests and commits retained; no competing store or parent claim.
2026-10-27 17:15:39 +01:00
lillith c86f2790fe WIP: compose the shared sketch store on Drawing's K receiver (#7082)
Adapt the existing W38.0 routing from CamWight/solidcraft #6336 at
079846530c, without importing newer main
features absent from this receiver. The complete original sketch_store.rs
module is byte-identical under LF normalization, including its three tests.
The original routing work is Cameron Wight's; this commit adapts its callers,
adds two receiver-isolation tests and records the exact scope.

Original source commits: 2310b511, c806f606, 2307757a.
The later fb99e735 and 07984653 callers do not exist on this K receiver.
Preserve the receiver's run_links semantics, design-only access and all
original test expectations. No active drawing sketch or UI is enabled.

Static source/format checks pass. Native build was not admitted below 4 GiB
free RAM. One metadata-only check was stopped at the unchanged 2 GiB
safety floor before reaching engine; no Rust test or full CI pass is claimed.
Explicit user-requested WIP preservation; stays draft and needs validation.

Co-authored-by: Cameron Wight <camfrawig@hotmail.co.uk>
2026-10-27 16:07:45 +01:00
lillith 5b064e8e26 feat(drawing): resolve and apply sheet-sketch edits atomically (#7082)
Reuse the existing parameter evaluator and sketch solver with read-only source values and exact drawing ownership. Ten original cases; complete doc library 330/0/1 existing ignore, strict scoped Clippy and wasm pass. Full workspace gate remains unqualified on this drawings receiver. No commands, lifecycle, kernel, schema or CI changes.
2026-10-27 15:10:42 +01:00
lillith bc51e0f80a test(drawing): verify sheet sketches through design containers (#7082)
Three original real-file acceptance cases in the existing IO drawing tests. Production/schema unchanged. Full affected IO suite, strict scoped Clippy and wasm pass; earlier incomplete full-workspace gate stays explicit in draft #7608. No editor/lifecycle completion claimed.
2026-10-27 13:49:51 +01:00
lillith 2deda2276d feat(drawing): store bounded sheet-sketch records (#7082)
First data-only W38a increment on the existing embedded drawing receiver. Native doc library, strict scoped Clippy and wasm pass; the actual full-repository gate result remains explicit in draft PR #7608. No editor, kernel or CI changes.
2026-10-27 12:54:36 +01:00
lillith e0cd0c982b claim: #7082 2026-10-27 11:37:27 +01:00
Cameron Wight 36cf48a3f6 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>
2026-10-27 04:30:30 +00:00
Cameron Wight e0cb46c1ef 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>
2026-10-27 04:30:29 +00:00
Cameron Wight 472a330691 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>
2026-10-27 04:30:28 +00: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
112 changed files with 22005 additions and 570 deletions
+2 -1
View File
@@ -174,7 +174,8 @@ A change that saves something new in a design adds ONE file, `crates/doc/src/for
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
works out the number (the next one while anything is pending), and the release folds pending
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md. Drawings work the same
way with their own numbers: one file in `crates/doc/src/drawing/format/pending/`.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
+3 -1
View File
@@ -255,7 +255,9 @@ a conflict. `crates/doc/src/format/pending/README.md` has an example.
**At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards. Drawings
follow the same rules with their own numbers, from 1, in `crates/doc/src/drawing/format/` (see its
`pending/README.md`), and the same script folds them.
## Lists many changes add to
+3 -2
View File
@@ -172,12 +172,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
+10
View File
@@ -563,6 +563,16 @@
"cmd.document_metadata_remove": "Remove Design Property",
"cmd.document_metadata_set": "Change Design Properties",
"cmd.document_units": "Change Design Units",
"cmd.drawing_close": "Close Drawing",
"cmd.drawing_create": "Create Drawing",
"cmd.drawing_delete": "Delete Drawing",
"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_set": "Drawing Settings",
"cmd.edit_checkpoint": "Checkpoint",
"cmd.edit_restore_checkpoint": "Restore Checkpoint",
"cmd.engine_commands": "List Commands",
+10
View File
@@ -563,6 +563,16 @@
"cmd.document_metadata_remove": "デザインのプロパティを削除",
"cmd.document_metadata_set": "デザインのプロパティを変更",
"cmd.document_units": "デザインの単位を変更",
"cmd.drawing_close": "図面を閉じる",
"cmd.drawing_create": "図面を作成",
"cmd.drawing_delete": "図面を削除",
"cmd.drawing_inspect": "図面を確認",
"cmd.drawing_list": "図面を一覧表示",
"cmd.drawing_open": "図面を開く",
"cmd.drawing_properties": "図面のプロパティ",
"cmd.drawing_properties_remove": "図面のプロパティを削除",
"cmd.drawing_properties_set": "図面のプロパティを変更",
"cmd.drawing_set": "図面設定",
"cmd.edit_checkpoint": "チェックポイント",
"cmd.edit_restore_checkpoint": "チェックポイントを復元",
"cmd.engine_commands": "コマンドを一覧表示",
@@ -0,0 +1,7 @@
{
"en": {
"cmd.drawing_sketch_create": "Create Sheet Sketch",
"cmd.drawing_sketch_edit": "Edit Sheet Sketch",
"cmd.drawing_sketch_inspect": "Inspect Sheet Sketch"
}
}
+10
View File
@@ -563,6 +563,16 @@
"cmd.document_metadata_remove": "删除设计属性",
"cmd.document_metadata_set": "更改设计属性",
"cmd.document_units": "更改设计单位",
"cmd.drawing_close": "关闭绘图",
"cmd.drawing_create": "创建绘图",
"cmd.drawing_delete": "删除绘图",
"cmd.drawing_inspect": "检查绘图",
"cmd.drawing_list": "列出绘图",
"cmd.drawing_open": "打开绘图",
"cmd.drawing_properties": "绘图属性",
"cmd.drawing_properties_remove": "删除绘图属性",
"cmd.drawing_properties_set": "更改绘图属性",
"cmd.drawing_set": "绘图设置",
"cmd.edit_checkpoint": "检查点",
"cmd.edit_restore_checkpoint": "恢复检查点",
"cmd.engine_commands": "列出命令",
+43 -17
View File
@@ -1,15 +1,40 @@
//! Lists the design-format changes in `src/format/`: one file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The list is
//! Lists the format changes of each family of files versioned by `src/format.rs`: designs in
//! `src/format/`, drawings in `src/drawing/format/` (#3890). One file per change, in `v<N>/` once a
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The lists are
//! generated so that a change adds a file and never edits a line another change also edits.
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
/// The last format whose upgrade steps are written out in `format.rs` (`JSON_STEPS`).
const HAND_WRITTEN: u32 = 22;
/// A family's change files: their folder, the last format before them, and the prefix of the
/// family's lists in `format.rs`.
struct Family {
folder: &'static str,
base: u32,
list_prefix: &'static str,
}
/// Every family, each read by `scripts/fold-format-pending.sh` too. A design's upgrade steps are
/// written out in `format.rs` up to 22 (`JSON_STEPS`); a drawing's first format is 1.
const FAMILIES: [Family; 2] =
[Family { folder: "src/format", base: 22, list_prefix: "" }, Family { folder: "src/drawing/format", base: 1, list_prefix: "DRAWING_" }];
fn main() {
let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/format");
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let mut out = String::new();
let mut mods = String::new();
for family in &FAMILIES {
lists(family, &manifest, &mut out, &mut mods);
}
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, out).unwrap();
}
/// A family's `FOLDED` and `PENDING` lists; its folded formats follow on from its base.
fn lists(family: &Family, manifest: &Path, out: &mut String, mods: &mut String) {
let Family { folder, base, list_prefix } = family;
let root = manifest.join(folder);
println!("cargo:rerun-if-changed={}", root.display());
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root)
.unwrap_or_else(|e| panic!("{}: {e}", root.display()))
@@ -21,23 +46,24 @@ fn main() {
.collect();
folded.sort();
for (i, (version, _)) in folded.iter().enumerate() {
let expected = HAND_WRITTEN + 1 + i as u32;
assert_eq!(*version, expected, "src/format/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
let expected = base + 1 + i as u32;
assert_eq!(*version, expected, "{folder}/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
}
let mut out = String::new();
let mut mods = String::new();
out.push_str("/// The changes of each format after the hand-written ones, oldest first.\nconst FOLDED: &[(u32, &[Part])] = &[\n");
// Module names start with the family's (`drawing_pending_…`), so two families' changes of one
// name never clash.
let module = list_prefix.to_ascii_lowercase();
let _ = writeln!(
out,
"/// The changes of each format after the base, oldest first (`{folder}/v<N>/`).\nconst {list_prefix}FOLDED: &[(u32, &[Part])] = &["
);
for (version, dir) in &folded {
let _ = writeln!(out, " ({version}, &[");
parts(dir, &format!("v{version}"), &mut out, &mut mods);
parts(dir, &format!("{module}v{version}"), out, mods);
out.push_str(" ]),\n");
}
out.push_str("];\n\n/// The changes no release has a number for yet (`src/format/pending/`).\nconst PENDING: &[Part] = &[\n");
parts(&root.join("pending"), "pending", &mut out, &mut mods);
out.push_str("];\n");
out.push_str(&mods);
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
std::fs::write(dest, out).unwrap();
let _ = writeln!(out, "];\n\n/// The changes no release has a number for yet (`{folder}/pending/`).\nconst {list_prefix}PENDING: &[Part] = &[");
parts(&root.join("pending"), &format!("{module}pending"), out, mods);
out.push_str("];\n\n");
}
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
+42
View File
@@ -124,6 +124,48 @@ fn identity() -> Mat {
IDENTITY
}
/// 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`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BomType {
/// A row of its own; every occurrence adds to its quantity.
#[default]
Normal,
/// No row of its own: what it holds counts as if placed directly in its parent.
Phantom,
/// Shown, but counted nowhere, and nothing inside it is counted either.
Reference,
/// Bought: one row, with nothing inside it counted or listed.
Purchased,
/// Made as one item that doesn't come apart (a welded frame): one row in the assembly it is
/// placed in. What it holds is listed in its own bill of materials, and its purchased parts
/// also in the parts-only list of the assembly it is placed in.
Inseparable,
}
impl BomType {
/// Every type, in the order commands and documentation list them.
pub const ALL: [BomType; 5] = [BomType::Normal, BomType::Phantom, BomType::Reference, BomType::Purchased, BomType::Inseparable];
/// The word the design file and commands use.
pub fn as_str(self) -> &'static str {
match self {
BomType::Normal => "normal",
BomType::Phantom => "phantom",
BomType::Reference => "reference",
BomType::Purchased => "purchased",
BomType::Inseparable => "inseparable",
}
}
/// The type a word names, if it names one.
pub fn parse(word: &str) -> Option<BomType> {
BomType::ALL.into_iter().find(|t| t.as_str() == word)
}
}
/// How deep and how many placements [`Document::placements`] walks.
pub const MAX_PLACEMENT_DEPTH: usize = 64;
pub const MAX_PLACEMENTS: usize = 100_000;
+33
View File
@@ -2486,6 +2486,12 @@ pub struct Document {
/// a document in a session leaves this empty; the session puts it back when 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>>,
}
/// Items hidden or shown one by one. Saved with the design, but not an undo step.
@@ -2686,6 +2692,7 @@ impl Document {
browser_order: Default::default(),
named_views: Vec::new(),
visibility: Visibility::default(),
drawings: Vec::new(),
}
}
@@ -2815,6 +2822,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`.)
+370
View File
@@ -0,0 +1,370 @@
//! 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.
//!
//! - 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
//! changes a saved drawing.
//! - **Units** follow each view's design or are one length unit for the whole drawing. The
//! **language** is the content's, a BCP 47 tag chosen when the drawing is made, whatever the
//! interface's is. **Properties** follow [`Metadata`]'s rules.
//!
//! The 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
//! the same one, and written as 13 characters of base32. No two objects share an id, and a run
//! never gives a removed object's id again ([`Drawing::allocate_id`]).
//! - Every reference between records is an id and must resolve: a file with one that doesn't is
//! damaged and refused, never patched.
//! - 1 to [`MAX_SHEETS`] sheets; names of 1 to [`MAX_NAME_BYTES`] bytes.
//! - Nothing derived is saved, and a drawing always saves as the same bytes: fields in their
//! order, maps sorted, and lists of records in a fixed order (sheets as arranged; later
//! records by id), never the order they were added in.
//! - A later change to the record is one file in `src/drawing/format/pending/` (its README).
//!
//! [`Drawing::from_json`] reads a drawing in this order: at most [`MAX_DRAWING_BYTES`] of text,
//! the family's format check, every number in range, the upgrade, the typed record, then
//! [`Drawing::validate`]; [`Drawing::from_value`] the same from the format check on.
mod sketch;
pub use sketch::{MAX_SHEET_SKETCHES, SheetSketch, SolvedSheetSketch};
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile};
use crate::format::DRAWING;
use crate::ids::{self, IdSource};
use crate::{DocError, Metadata, Result, expr};
/// 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 longest name of a drawing or a sheet, in bytes.
pub const MAX_NAME_BYTES: usize = 256;
/// 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.
pub const MAX_DRAWING_BYTES: usize = 16 << 20;
/// The resolution a sheet's pictures (its shaded views) are rendered at unless it says, in dots
/// per inch.
pub const DEFAULT_DPI: f64 = 300.0;
/// The lowest resolution a sheet may ask for.
pub const MIN_DPI: f64 = 25.0;
/// The highest resolution a sheet may ask for.
pub const MAX_DPI: f64 = 2400.0;
/// A drawing: its sheets and the designs they draw.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Drawing {
/// `solidcraft-drawing/N`: the format this build writes, whatever the file said.
pub format: String,
/// The changes of `format` this drawing has while they are pending
/// ([`Family::pending_names`](crate::format::Family::pending_names)). Empty, and not saved,
/// otherwise.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub format_pending: Vec<String>,
/// Unique among its design's drawings: a design container's entry for it is named by it.
#[serde(with = "crate::ids")]
pub id: u64,
pub name: String,
/// The designs the drawing draws: for now at most one, the design it is in.
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,
#[serde(default)]
pub units: Units,
/// The language of the drawing's content, a BCP 47 tag (`en-GB`).
pub language: String,
/// In order, each behind its own `Arc`, so an undo step shares every sheet it didn't change.
pub sheets: Vec<Arc<Sheet>>,
/// Authored sheet sketches, ordered by id on disk, with their original paper-mm payload.
#[serde(default, skip_serializing_if = "Vec::is_empty", serialize_with = "sketch::serialize_sorted")]
pub sketches: Vec<Arc<SheetSketch>>,
}
/// 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 length unit a drawing gives its measurements in.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Units {
/// Each view's design's own units.
#[default]
FollowModel,
/// One length unit for the whole drawing, by its name in [`expr::UNITS`] (`mm`, `in`).
Fixed { unit: String },
}
/// One sheet of a drawing.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Sheet {
#[serde(with = "crate::ids")]
pub id: u64,
pub name: String,
/// The paper: its size and the way it lies.
pub sheet: drawing_sheet::Sheet,
/// A copy of the drawing border's profile (`None`: no drawing border).
pub border: Option<DrawingBorderProfile>,
/// The resolution its pictures (its shaded views) are rendered at, in dots per inch.
#[serde(default = "default_dpi")]
pub dpi: f64,
/// The component the sheet documents, which its title block reads.
pub documents: Option<SheetComponent>,
#[serde(default)]
pub excluded: Excluded,
}
/// A component of one of the drawing's designs.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SheetComponent {
/// The [`Source`]'s id.
#[serde(with = "crate::ids")]
pub source: u64,
/// The component's id in that design, as the design writes it (0: the design itself).
pub component: u64,
}
/// What a sheet is left out of.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Excluded {
/// The sheet count ("sheet 2 of 3" on the other sheets).
pub count: bool,
/// Printing and exporting every sheet.
pub print: bool,
}
fn default_dpi() -> f64 {
DEFAULT_DPI
}
impl Drawing {
/// A drawing of no designs yet, named `name` and written in `language`, with one sheet named
/// `sheet`: the paper `paper` with a copy of `border`. Its id, and then its sheet's, come from
/// `id_source`, the drawing's differing from every one in `taken` (its design's drawings').
pub fn new(
name: &str,
language: &str,
sheet: &str,
paper: drawing_sheet::Sheet,
border: Option<DrawingBorderProfile>,
taken: &BTreeSet<u64>,
id_source: &mut IdSource,
) -> Result<Drawing> {
let id = id_source.next(taken)?;
let sheet_id = id_source.next(&BTreeSet::from([id]))?;
let first =
Sheet { id: sheet_id, name: sheet.into(), sheet: paper, border, 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(),
units: Units::default(),
language: language.into(),
sheets: vec![Arc::new(first)],
sketches: Vec::new(),
};
drawing.validate()?;
Ok(drawing)
}
/// 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 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())?;
sketch::check_raw(&v).map_err(damaged)?;
let mut d: Drawing = serde_json::from_value(v).map_err(damaged)?;
d.sketches.sort_by_key(|record| record.id);
d.validate().map_err(damaged)?;
Ok(Drawing { format: DRAWING.format_string(DRAWING.current()), format_pending: DRAWING.pending_names(), ..d })
}
/// The drawing's JSON: the same bytes for the same drawing. JSON longer than a read takes
/// ([`MAX_DRAWING_BYTES`]) is refused, so every drawing saved opens again: other tools'
/// properties are kept as they came, and indenting them can make a drawing that read in
/// under the limit save over it.
pub fn to_json(&self) -> Result<String> {
self.validate()?;
let text = serde_json::to_string_pretty(self).map_err(|e| DocError::Invalid(format!("drawing not saved: {e}")))?;
require(text.len() <= MAX_DRAWING_BYTES, || format!("drawing too large to save (at most {} MiB)", MAX_DRAWING_BYTES >> 20))?;
Ok(text)
}
/// An id for a new object: one no object of the drawing has, from `id_source`, which never
/// gives one twice.
pub fn allocate_id(&self, id_source: &mut IdSource) -> Result<u64> {
id_source.next(&self.ids().collect())
}
/// Read only this drawing's sketch. Identical local sketch ids in another drawing are unrelated.
pub fn sheet_sketch(&self, id: u64) -> Option<&Arc<SheetSketch>> {
self.sketches.iter().find(|record| record.id == id)
}
/// Every object's id, of every kind: what a new id must differ from. Each later kind adds its
/// own here.
fn ids(&self) -> impl Iterator<Item = u64> {
std::iter::once(self.id)
.chain(self.sources.iter().map(|s| s.id))
.chain(self.sheets.iter().map(|s| s.id))
.chain(self.sketches.iter().map(|s| s.id))
}
/// The record's rules (the module's notes): what reading a drawing checks, and what every edit
/// keeps.
pub fn validate(&self) -> Result<()> {
self.validate_structure()?;
sketch::check_drawing(self)
}
/// Ownership/header checks also used before staging one sketch edit. Deep
/// payload validation remains at the full publication and file boundaries.
fn validate_structure(&self) -> Result<()> {
require(self.sketches.len() <= MAX_SHEET_SKETCHES, || "too many sheet sketches".into())?;
require((1..=MAX_SHEETS).contains(&self.sheets.len()), || format!("a drawing has 1 to {MAX_SHEETS} sheets"))?;
require(self.sources.len() <= 1, || "a drawing draws only the design it is in".into())?;
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`)")
})?;
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);
require(length, || "the drawing's unit is not a length unit (such as `mm` or `in`)".into())?;
}
let sheets: BTreeSet<_> = self.sheets.iter().map(|sheet| sheet.id).collect();
for record in &self.sketches {
require(sheets.contains(&record.sheet), || "sheet sketch owner does not exist in this drawing".into())?;
}
let mut seen = BTreeSet::new();
for id in self.ids() {
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 sheet in &self.sheets {
self.check_sheet(sheet).map_err(|e| DocError::Invalid(format!("sheet {}: {e}", ids::encode(sheet.id))))?;
}
Ok(())
}
/// A sheet's own rules, and that the source it documents is one of the drawing's.
fn check_sheet(&self, sheet: &Sheet) -> Result<()> {
check_name(&sheet.name, "its")?;
sheet.sheet.size_mm().map_err(from_sheet)?;
if let Some(border) = &sheet.border {
border.validate().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 {
Some(SheetComponent { source, .. }) => {
let named = || format!("it documents source {}, which the drawing doesn't have", ids::encode(source));
require(self.sources.iter().any(|s| s.id == source), named)
}
None => Ok(()),
}
}
}
/// 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| require(seen.insert(d.id), || format!("two drawings have the id {}", ids::encode(d.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"))
}
/// `Ok` when `ok`, else the rule `broken` names.
fn require(ok: bool, broken: impl FnOnce() -> String) -> Result<()> {
if ok { Ok(()) } else { Err(DocError::Invalid(broken())) }
}
/// A name: 1 to [`MAX_NAME_BYTES`] bytes, not only spaces, without control characters.
fn check_name(name: &str, whose: &str) -> Result<()> {
require(!name.trim().is_empty() && name.len() <= MAX_NAME_BYTES && !name.chars().any(char::is_control), || {
format!("{whose} name is 1 to {MAX_NAME_BYTES} bytes, not only spaces, without control characters")
})
}
/// A well-formed language tag, as far as a drawing needs (RFC 5646 §2.1): 2 to
/// [`MAX_LANGUAGE_CHARS`] characters, subtags of 1 to 8 ASCII letters and digits joined by `-`,
/// the first of 2 to 8 letters (`en`, `en-GB`, `zh-Hans-CN`). Whether the subtags are registered
/// isn't checked, and a private-use tag alone (`x-…`) isn't taken.
fn is_language_tag(tag: &str) -> bool {
let mut subtags = tag.split('-');
let first = subtags.next().unwrap_or_default();
(2..=MAX_LANGUAGE_CHARS).contains(&tag.len())
&& (2..=8).contains(&first.len())
&& first.bytes().all(|b| b.is_ascii_alphabetic())
&& subtags.all(|s| (1..=8).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric()))
}
/// A file that breaks a rule of the record.
fn damaged(e: impl std::fmt::Display) -> DocError {
DocError::Invalid(format!("damaged drawing: {e}"))
}
fn from_sheet(e: drawing_sheet::Invalid) -> DocError {
DocError::Invalid(e.to_string())
}
#[cfg(test)]
#[path = "drawing_tests.rs"]
mod tests;
@@ -0,0 +1,30 @@
# Pending drawing-format changes
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.
- Name it after the change, lowercase with dashes: `sheet-views.rs`.
- Start it with a `//!` note: what is saved, what an older drawing gets without it, and what
the last release does with a drawing that has it (it refuses the newer format number).
- If older drawings must be rewritten as they open, add the step to the same file:
```rust
//! …the note…
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
// rewrite the drawing's JSON here
Ok(())
}
```
Steps run in file-name order, after the steps of every older format.
While this folder has a change in it, the build writes the next drawing format number (the last
folded one + 1, which no release reads), and lists the changes a drawing has in its
`format_pending` field, so a later build applies only the steps that drawing is missing. At
release time `scripts/fold-format-pending.sh` moves these files into `../v<N>/`, as it does the
design format's (`crates/doc/src/format/pending/README.md`).
@@ -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 @@
//! Optional drawing-owned sheet sketches (#7082), with drawing-global ids, explicit sheet
//! ownership and the original Sketch payload in paper millimetres. Empty/absent sketches keep
//! the old record shape. No migration: old drawings have no sketches; serde supplies an empty
//! list. A build without this pending change refuses its newer format instead of dropping it.
+385
View File
@@ -0,0 +1,385 @@
//! W38a's first record slice: sheet-owned, paper-mm geometry, with no model evaluation.
//! The Sketch payload stays the shared type. Links, patterns and offsets await the
//! receiver-aware store: refusing them here is explicit, never silent data loss.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value;
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, MAX_CONSTRAINTS, MAX_COORD, MAX_CURVES, MAX_POINTS, MAX_SPLINE_POINTS, Sketch};
use super::{Drawing, MAX_DRAWING_BYTES, check_name, require};
use crate::{DocError, Result};
/// Maximum sheet sketches in one drawing; independently of the number of sheets.
pub const MAX_SHEET_SKETCHES: usize = 1000;
// One drawing cannot multiply the shared sketch array caps by its sketch count.
const MAX_ENTITIES: usize = MAX_POINTS + MAX_CURVES + MAX_CONSTRAINTS;
const MAX_VALIDATION_WORK: usize = 2_000_000;
const MAX_STORED_TEXT_BYTES: usize = 4096;
const MAX_LOCAL_ID_BYTES: usize = 512;
const AUXILIARY: [&str; 5] = ["links", "patterns", "offsets", "freeform_offsets", "wires"];
/// Authored sheet geometry. Points and lengths are paper XY millimetres, regardless
/// of the drawing's display units. The origin is the sheet's lower-left corner,
/// with x right and y up, not the border corner. No view scale or source path.
/// Expressions are retained verbatim; interpreting them is the later read-only store's job.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SheetSketch {
#[serde(with = "crate::ids")]
pub id: u64,
#[serde(with = "crate::ids")]
pub sheet: u64,
pub name: String,
pub sketch: Sketch,
}
fn invalid(message: impl Into<String>) -> DocError {
DocError::Invalid(format!("sheet sketch: {}", message.into()))
}
fn charge(used: &mut usize, amount: usize, limit: usize, what: &str) -> Result<()> {
*used = used.checked_add(amount).filter(|n| *n <= limit).ok_or_else(|| invalid(format!("{what} limit exceeded")))?;
Ok(())
}
fn text(s: &str) -> Result<()> {
require(!s.trim().is_empty() && s.len() <= MAX_STORED_TEXT_BYTES && !s.chars().any(char::is_control), || {
"sheet sketch expression is empty, too long or contains control characters".into()
})
}
fn position(p: Vec2) -> Result<()> {
require(p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD, || "sheet sketch point is out of range".into())
}
fn identity<'a>(seen: &mut BTreeSet<&'a str>, id: &'a str) -> Result<()> {
require(!id.trim().is_empty() && id.len() <= MAX_LOCAL_ID_BYTES && !id.chars().any(char::is_control), || {
"sheet sketch local id is empty, too long or contains control characters".into()
})?;
require(seen.insert(id), || "sheet sketch local ids must be unique".into())
}
/// Every collection is bounded before serde allocates the typed sketch payload.
/// A raw JSON Value already exists here; the drawing reader separately caps encoded bytes.
pub(super) fn check_raw(value: &Value) -> Result<()> {
let Some(records) = value.get("sketches") else { return Ok(()) };
let records = records.as_array().ok_or_else(|| invalid("sketches must be an array"))?;
require(records.len() <= MAX_SHEET_SKETCHES, || "too many sheet sketches".into())?;
let mut total = 0;
let mut nodes = 0;
let mut bytes = 0;
for record in records {
raw_tree(record, 0, &mut nodes, &mut bytes)?;
let Some(sketch) = record.get("sketch") else { continue };
for (key, limit) in [("points", MAX_POINTS), ("curves", MAX_CURVES), ("constraints", MAX_CONSTRAINTS)] {
if let Some(array) = sketch.get(key) {
let array = array.as_array().ok_or_else(|| invalid(format!("{key} must be an array")))?;
require(array.len() <= limit, || format!("sheet sketch {key} limit exceeded"))?;
charge(&mut total, array.len(), MAX_ENTITIES, "drawing sketch entity")?;
}
}
for key in AUXILIARY {
if let Some(array) = sketch.get(key) {
let array = array.as_array().ok_or_else(|| invalid(format!("{key} must be an array")))?;
require(array.is_empty(), || format!("not supported yet: sheet sketch {key}"))?;
}
}
if let Some(curves) = sketch.get("curves").and_then(Value::as_array) {
for curve in curves {
if let Some(points) = curve.get("pts").and_then(Value::as_array) {
require(points.len() <= MAX_SPLINE_POINTS, || "sheet sketch spline point limit exceeded".into())?;
charge(&mut total, points.len(), MAX_ENTITIES, "drawing sketch entity")?;
}
}
}
if let Some(counters) = sketch.get("counters").and_then(Value::as_object) {
require(counters.len() <= 64, || "sheet sketch counter prefix limit exceeded".into())?;
require(counters.keys().all(|key| key.len() <= MAX_LOCAL_ID_BYTES), || "sheet sketch counter prefix too long".into())?;
}
}
Ok(())
}
// These guards apply to from_value as well as from_json, and precede typed decoding.
fn raw_tree(value: &Value, depth: usize, nodes: &mut usize, bytes: &mut usize) -> Result<()> {
require(depth <= 32, || "sheet sketch payload nested too deeply".into())?;
charge(nodes, 1, MAX_ENTITIES * 16, "raw node")?;
match value {
Value::String(s) => {
require(s.len() <= MAX_STORED_TEXT_BYTES, || "sheet sketch stored text too long".into())?;
charge(bytes, s.len(), MAX_DRAWING_BYTES, "stored text")?;
}
Value::Array(array) => {
require(array.len() <= MAX_ENTITIES, || "sheet sketch raw array too large".into())?;
for item in array {
raw_tree(item, depth + 1, nodes, bytes)?;
}
}
Value::Object(object) => {
require(object.len() <= MAX_ENTITIES, || "sheet sketch raw object too large".into())?;
for (key, item) in object {
require(key.len() <= MAX_LOCAL_ID_BYTES, || "sheet sketch field name too long".into())?;
charge(bytes, key.len(), MAX_DRAWING_BYTES, "stored text")?;
raw_tree(item, depth + 1, nodes, bytes)?;
}
}
_ => {}
}
Ok(())
}
/// Cheap cumulative admission precedes validation/solving of any candidate. It
/// counts unchanged records too: editing one record cannot exceed a drawing cap.
fn check_budget<'a>(records: impl Iterator<Item = &'a SheetSketch>) -> Result<()> {
let mut entities = 0;
let mut work = 0;
for record in records {
let sk = &record.sketch;
for (count, cap) in [(sk.points.len(), MAX_POINTS), (sk.curves.len(), MAX_CURVES), (sk.constraints.len(), MAX_CONSTRAINTS)] {
require(count <= cap, || "sheet sketch array limit exceeded".into())?;
charge(&mut entities, count, MAX_ENTITIES, "drawing sketch entity")?;
}
for curve in &sk.curves {
if let CurveKind::Spline { pts, .. } = &curve.kind {
require(pts.len() <= MAX_SPLINE_POINTS, || "sheet sketch spline point limit exceeded".into())?;
charge(&mut entities, pts.len(), MAX_ENTITIES, "drawing sketch entity")?;
}
}
for constraint in &sk.constraints {
// The shared slot validator can search the list twice. Keep the same
// conservative work charge, before invoking it, even on staged edits.
let cost = if matches!(constraint.kind, ConstraintKind::SlotOverallLength { .. }) {
sk.constraints
.len()
.checked_mul(sk.constraints.len())
.and_then(|n| n.checked_mul(4))
.ok_or_else(|| invalid("validation work overflow"))?
} else {
1
};
charge(&mut work, cost, MAX_VALIDATION_WORK, "validation work")?;
}
}
Ok(())
}
pub(super) fn check_drawing(drawing: &Drawing) -> Result<()> {
require(drawing.sketches.len() <= MAX_SHEET_SKETCHES, || "too many sheet sketches".into())?;
check_budget(drawing.sketches.iter().map(AsRef::as_ref))?;
for record in &drawing.sketches {
check_payload(record)?;
}
Ok(())
}
// Only after check_budget: the single payload is checked before and after the
// solver, while the Session's publication boundary checks the complete drawing.
fn check_payload(record: &SheetSketch) -> Result<()> {
check_name(&record.name, "sheet sketch")?;
let sk = &record.sketch;
require(sk.links.is_empty() && sk.patterns.is_empty() && sk.offsets.is_empty() && sk.freeform_offsets.is_empty() && sk.wires.is_empty(), || {
"not supported yet: sheet sketch links, patterns, offsets or 3D wires".into()
})?;
require(!sk.view.three_d && !sk.view.slice, || "model-only sketch view is not a sheet sketch".into())?;
let origin = sk.points.first().ok_or_else(|| invalid("missing sketch origin"))?;
require(origin.id == "origin" && origin.pos == Vec2::ZERO && origin.fixed && origin.link.is_none() && origin.exprs.is_none(), || {
"sheet sketch point zero must be its fixed local origin".into()
})?;
let mut seen = BTreeSet::new();
for point in &sk.points {
identity(&mut seen, &point.id)?;
position(point.pos)?;
require(point.link.is_none(), || "not supported yet: linked sheet sketch point".into())?;
if let Some(expressions) = &point.exprs {
for expression in [&expressions.x, &expressions.y].into_iter().flatten() {
text(expression)?;
}
}
}
for curve in &sk.curves {
identity(&mut seen, &curve.id)?;
require(curve.link.is_none(), || "not supported yet: linked sheet sketch curve".into())?;
if let CurveKind::Spline { pts, degree, control, .. } = &curve.kind {
require(
(2..=MAX_SPLINE_POINTS).contains(&pts.len()) && (1..=5).contains(degree) && (!control || pts.len() > usize::from(*degree)),
|| "invalid sheet sketch spline size or degree".into(),
)?;
}
require(curve.kind.point_ids().iter().all(|&i| i < sk.points.len()), || "sheet sketch curve references an unknown point".into())?;
match curve.kind {
CurveKind::Line { a, b } => require(a != b, || "sheet sketch line needs distinct point identities".into())?,
CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } => {
require(r.is_finite() && r > 0.0 && r < MAX_COORD, || "sheet sketch radius is out of range".into())?;
}
CurveKind::Conic { rho, .. } => require(rho.is_finite() && rho > 0.0 && rho < 1.0, || "sheet sketch conic rho is out of range".into())?,
_ => {}
}
require(!curve.infinite || (curve.construction && matches!(curve.kind, CurveKind::Line { .. })), || {
"only a construction line can be infinite in a sheet sketch".into()
})?;
}
for constraint in &sk.constraints {
identity(&mut seen, &constraint.id)?;
sk.validate(&constraint.kind).map_err(|e| invalid(e.to_string()))?;
if let Some(value) = &constraint.param {
text(value)?;
}
if let Some(at) = constraint.text {
position(at)?;
}
}
sk.validate_periodic_splines().map_err(|e| invalid(e.to_string()))?;
Ok(())
}
/// Sort references, not the live geometry. Sheet order stays authored; sketch
/// record order depends only on ids, so insertion order never changes saved bytes.
pub(super) fn serialize_sorted<S: Serializer>(records: &[Arc<SheetSketch>], serializer: S) -> std::result::Result<S::Ok, S::Error> {
if records.len() > MAX_SHEET_SKETCHES {
return Err(serde::ser::Error::custom("too many sheet sketches"));
}
let mut sorted: Vec<_> = records.iter().collect();
sorted.sort_by_key(|record| record.id);
sorted.serialize(serializer)
}
/// A sheet sketch resolved by the shared solver, in paper XY millimetres.
/// This is runtime data, not another stored sketch or a model feature. The drawing
/// and sheet ids remain explicit even when two drawings reuse the same sketch id.
#[derive(Clone, Debug)]
pub struct SolvedSheetSketch {
pub drawing: u64,
pub sheet: u64,
pub id: u64,
pub sketch: Sketch,
pub report: solidcraft_sketch::SolveReport,
}
impl crate::Document {
/// Resolve a stored sketch without modifying its record or the model. This
/// public document read checks the complete drawing, including other records.
pub fn solve_sheet_sketch(&self, drawing: u64, sketch: u64) -> Result<SolvedSheetSketch> {
super::check_count(self.drawings.len())?;
let mut owners = self.drawings.iter().filter(|owner| owner.id == drawing);
let owner = owners.next().ok_or_else(|| invalid("the containing design has no such drawing"))?;
require(owners.next().is_none(), || "sheet sketch owner id is ambiguous in this design".into())?;
owner.validate()?;
owner.solve_sheet_sketch(self, sketch)
}
}
impl Drawing {
/// Resolve one admitted record against the read-only source design. Checks
/// owner identities, cumulative budgets and this payload, not every unrelated
/// payload. File reads/saves and Session::edit_drawing validate the whole drawing.
pub fn solve_sheet_sketch(&self, design: &crate::Document, sketch: u64) -> Result<SolvedSheetSketch> {
self.validate_structure()?;
check_budget(self.sketches.iter().map(AsRef::as_ref))?;
let original = self.sheet_sketch(sketch).ok_or_else(|| invalid("the selected drawing has no such sheet sketch"))?;
solve_record(design, self, original, (**original).clone())
}
/// Stage an atomic edit of one sketch on this drawing, without writing to the
/// source Document. Commands call this inside Session::edit_drawing, the one
/// publication/whole-drawing validation boundary. A library caller adopting
/// this drawing must likewise validate it before publishing the complete record.
///
/// Failed callbacks, unsafe candidates and solve failures leave self unchanged.
/// Only the selected record is checked deeply here; unchanged Arcs are NOT a
/// certificate that other records are valid. Their validation is never skipped
/// by the outer publication boundary or the file reader/writer.
pub fn edit_sheet_sketch<T>(
&mut self,
design: &crate::Document,
sketch: u64,
change: impl FnOnce(&mut Sketch) -> Result<T>,
) -> Result<(T, SolvedSheetSketch)> {
self.validate_structure()?;
let original = self.sheet_sketch(sketch).cloned().ok_or_else(|| invalid("the selected drawing has no such sheet sketch"))?;
check_budget(self.sketches.iter().map(AsRef::as_ref))?;
check_payload(&original)?;
let mut candidate = (*original).clone();
let out = change(&mut candidate.sketch)?;
check_budget(self.sketches.iter().map(|record| if record.id == sketch { &candidate } else { record.as_ref() }))?;
let solved = solve_record(design, self, &original, candidate)?;
if solved.sketch != original.sketch {
let next = SheetSketch { sketch: solved.sketch.clone(), ..(*original).clone() };
check_budget(self.sketches.iter().map(|record| if record.id == sketch { &next } else { record.as_ref() }))?;
let slot = self.sketches.iter_mut().find(|record| record.id == sketch).ok_or_else(|| invalid("sheet sketch disappeared"))?;
*slot = Arc::new(next);
}
Ok((out, solved))
}
}
fn solve_record(design: &crate::Document, owner: &Drawing, original: &SheetSketch, mut candidate: SheetSketch) -> Result<SolvedSheetSketch> {
// Only the candidate is admitted before the solver sees its input-derived
// indices. No link/pattern/offset or 3D path bypasses the existing checks.
check_payload(&candidate)?;
let names = parameter_names(&candidate.sketch)?;
let mut values = if names.is_empty() {
std::collections::BTreeMap::new()
} else {
require(owner.sources.len() == 1, || "sheet sketch parameters need the drawing's declared design source".into())?;
let (values, errors) = design.param_values();
for name in &names {
if let Some(why) = errors.get(name) {
return Err(invalid(format!("source parameter `{name}`: {why}")));
}
require(values.contains_key(name), || format!("sheet sketch: unknown source parameter `{name}`"))?;
}
values
};
// A sheet dimension owns its authored expression, not a model parameter. Keep
// original source parameter values intact and add expression results under
// their exact stored keys, which the shared sketch solver already consumes.
// Mixed angle/length uses of an identical key would be ambiguous: refuse.
let mut expression_kinds = std::collections::BTreeMap::new();
for constraint in &candidate.sketch.constraints {
let Some(expression) = &constraint.param else { continue };
let is_angle = constraint.kind.is_angle();
if let Some(previous) = expression_kinds.insert(expression.as_str(), is_angle) {
require(previous == is_angle, || "one sheet expression cannot drive both lengths and angles".into())?;
}
if !values.contains_key(expression) {
let kind = if is_angle { crate::expr::Kind::Angle } else { crate::expr::Kind::Length };
let evaluated = crate::Document::eval_in(&values, expression, kind)?;
let resolved = if is_angle { crate::expr::Value::angle(evaluated) } else { crate::expr::Value::length(evaluated) };
values.insert(expression.clone(), resolved);
}
}
let stored = original.sketch.driving_values();
let report = design.solve_sketch(&values, &mut candidate.sketch, &stored, None)?;
require(report.keeps_orientation(), || report.failure(|conflicts| format!("sheet sketch edit would conflict with {conflicts}")))?;
check_budget(std::iter::once(&candidate))?;
check_payload(&candidate)?;
Ok(SolvedSheetSketch { drawing: owner.id, sheet: original.sheet, id: original.id, sketch: candidate.sketch, report })
}
/// Names are collected after record admission, with cumulative expression work
/// bounded by the existing drawing byte ceiling. Literal expressions need no
/// source and keep the expression engine's paper-mm/degree defaults.
fn parameter_names(sketch: &Sketch) -> Result<BTreeSet<String>> {
let mut names = BTreeSet::new();
let mut bytes = 0;
for point in &sketch.points {
if let Some(expressions) = &point.exprs {
for expression in [&expressions.x, &expressions.y].into_iter().flatten() {
charge(&mut bytes, expression.len(), MAX_DRAWING_BYTES, "expression text")?;
names.extend(crate::expr::references(expression));
}
}
}
for constraint in &sketch.constraints {
if let Some(name) = &constraint.param {
require(constraint.kind.is_dimension(), || "only a sheet sketch dimension can refer to a source parameter".into())?;
charge(&mut bytes, name.len(), MAX_DRAWING_BYTES, "expression text")?;
names.extend(crate::expr::references(name));
}
}
Ok(names)
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -469,7 +469,7 @@ fn mirror_y(r: &Region2) -> Region2 {
.iter()
.map(|s| match *s {
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep },
other => sheet::map_controls(&other, f),
other => other.map_controls(f),
})
.collect();
Loop2 { segs }.ccw()
+345 -73
View File
@@ -232,6 +232,11 @@
//! this build writes the next number (which no release reads), with the names of these changes
//! in `format_pending`, so a later build applies just the steps a file is missing. At release
//! time `scripts/fold-format-pending.sh` moves them into the next `v<N>/`.
//!
//! Drawings (`solidcraft-drawing/N`, #3890) are versioned by the same code ([`Family`]) from their
//! own first format, 1 (the record in [`crate::drawing`]): each later change is one file in
//! `src/drawing/format/`, in `v<N>/` or `pending/` as above, and the release script folds that
//! folder too.
use serde_json::Value;
@@ -244,11 +249,11 @@ pub const FORMAT: u32 = FORMATS.current();
/// of [`Part`]s (`src/format/`, listed by `build.rs`).
const HAND_WRITTEN: u32 = 22;
/// One change to the file format: a file in `src/format/v<N>/` or `src/format/pending/`.
/// One change to a family's file format: a file in its `v<N>/` or `pending/` folder.
pub(crate) struct Part {
/// The file's name, which a file written while the change was pending lists.
pub name: &'static str,
/// How a design from before the change is rewritten as it opens, if it is.
/// How a file from before the change is rewritten as it opens, if it is.
pub step: Option<JsonStep>,
}
@@ -257,13 +262,102 @@ include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
/// Every format this build knows and how to upgrade a design through them.
const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: PENDING };
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
FORMATS.pending.iter().map(|p| p.name.to_string()).collect()
/// A kind of file whose format is versioned here: designs, and drawings (#3890). Each family has
/// its own format tag, its own numbers and its own folder of change files (`build.rs` lists them);
/// reading a file's format, refusing a file a newer build saved and the upgrade are the same code
/// for every family.
pub struct Family<'a> {
/// What a file of the family is called in messages.
noun: &'static str,
/// The tag before the number in a file's `format`.
prefix: &'static str,
/// The tag files saved before the rename carry: read, never written (docs/compatibility.md).
legacy_prefix: Option<&'static str>,
/// The format of a file that names none: designs saved before format strings are format 1; a
/// drawing always names its own.
untagged: Option<u32>,
formats: Formats<'a>,
}
/// The upgrade path: the hand-written steps up to [`HAND_WRITTEN`], the folded formats after it
/// (each a list of changes) and the pending changes, which make the next format.
/// Designs (`solidcraft/N`): the upgrade steps written out up to format 22, then the change files
/// in `src/format/`.
pub const DESIGN: Family<'static> =
Family { noun: "design", prefix: "solidcraft/", legacy_prefix: Some(LEGACY_FORMAT_PREFIX), untagged: Some(1), formats: FORMATS };
/// Drawings (`solidcraft-drawing/N`, #3890): format 1 is the first drawing record, and every later
/// change is a file in `src/drawing/format/`.
pub const DRAWING: Family<'static> = Family {
noun: "drawing",
prefix: "solidcraft-drawing/",
legacy_prefix: None,
untagged: None,
formats: Formats { hand: &[], folded: DRAWING_FOLDED, pending: DRAWING_PENDING },
};
impl Family<'_> {
/// The format this build writes: the last folded one, or the next while changes are pending.
pub const fn current(&self) -> u32 {
self.formats.current()
}
/// The names of the pending changes this build writes into a file (`format_pending`).
pub fn pending_names(&self) -> Vec<String> {
self.formats.pending.iter().map(|p| p.name.to_string()).collect()
}
/// The format tag of format `n`.
pub fn format_string(&self, n: u32) -> String {
format!("{}{n}", self.prefix)
}
/// The version in one of this family's format tags; `None` for any other tag, another
/// family's included.
pub fn version_of(&self, s: &str) -> Option<u32> {
s.strip_prefix(self.prefix).or_else(|| self.legacy_prefix.and_then(|p| s.strip_prefix(p)))?.parse().ok()
}
/// Read a file's format and take its `format_pending` out of its JSON, before anything else
/// in it is read: a file that isn't this family's, or that a newer build saved, is refused
/// here, so nothing a newer build saved is judged by this build's rules.
pub fn take_version(&self, v: &mut Value) -> Result<(u32, Option<Vec<String>>)> {
let noun = self.noun;
let not_ours = |what: String| DocError::Invalid(format!("not a SolidCraft {noun} ({what})"));
let Some(obj) = v.as_object() else { return Err(not_ours("expected a JSON object".into())) };
let version = match obj.get("format") {
None => self.untagged.ok_or_else(|| not_ours("no format".into()))?,
Some(Value::String(f)) => self.version_of(f).ok_or_else(|| not_ours(format!("format `{f}`")))?,
Some(_) => return Err(not_ours("format is not a string".into())),
};
let applied = take_names(v, "format_pending", noun)?;
self.formats.check(noun, version, applied.as_deref())?;
Ok((version, applied))
}
/// Bring a file of format `version` up to this build's; `applied` is its `format_pending`
/// (both from [`Family::take_version`]).
pub fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
self.formats.upgrade(self.noun, v, version, applied)
}
/// Every number a file of the family holds is in range ([`check_numbers`]), but for its
/// top-level `kept` field: other tools' data (a design's `metadata`, a drawing's `properties`),
/// kept as it is and never read as a number (#1015); parsing already bounds its nesting.
pub(crate) fn check_numbers(&self, v: &Value, kept: &str) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != kept).try_for_each(|(_, x)| check_numbers(x, self.noun, 1)),
v => check_numbers(v, self.noun, 0),
}
}
}
/// The names of the pending changes this build writes into a design (`format_pending`).
pub fn pending_names() -> Vec<String> {
DESIGN.pending_names()
}
/// A family's upgrade path: the steps written out by hand (designs' up to [`HAND_WRITTEN`]), the
/// folded formats after them (each a list of changes) and the pending changes, which make the
/// next format.
struct Formats<'a> {
hand: &'a [JsonStep],
folded: &'a [(u32, &'a [Part])],
@@ -285,23 +379,35 @@ impl Formats<'_> {
}
}
/// Bring a design of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first. A pending change this build doesn't have is a
/// newer build's, and the design is refused.
fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
if version == 0 || version > self.current() {
return Err(DocError::Invalid(format!("not a SolidCraft design (format {version})")));
/// Refuse a `noun` of format `version` that this build can't read: format 0, a newer number,
/// or a pending change `applied` (its `format_pending`) names that this build doesn't have,
/// which only a newer build's file of the pending number can. A list beside a format that
/// isn't pending means nothing (a tool or a test set an older number on a newer file).
fn check(&self, noun: &str, version: u32, applied: Option<&[String]>) -> Result<()> {
let newer = |what: String| DocError::Invalid(format!("this {noun} was saved by a newer SolidCraft ({what}): update SolidCraft to open it"));
let current = self.current();
if version == 0 {
return Err(DocError::Invalid(format!("not a SolidCraft {noun} (format 0)")));
}
// A list beside a format that isn't made of changes means nothing (a tool or a test set
// an older number on a newer file): it is ignored, like any field an upgrade drops.
if version > current {
return Err(newer(format!("file format {version}; this version reads formats up to {current}")));
}
let pending = version == current && !self.pending.is_empty();
match applied.unwrap_or_default().iter().find(|name| pending && !self.pending.iter().any(|p| p.name == name.as_str())) {
Some(unknown) => Err(newer(format!("file format {version} with `{unknown}`, which this version doesn't know"))),
None => Ok(()),
}
}
/// Bring a `noun` of format `version` up to [`Formats::current`]. `applied` is the file's
/// `format_pending`: the changes of its format it already has, when it was written while they
/// were pending; the others' steps run first. A file [`Formats::check`] refuses is refused,
/// whoever calls: the steps below need a format from 1 to the current one.
fn upgrade(&self, noun: &str, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
self.check(noun, version, applied)?;
// A list beside a format that isn't made of changes is ignored, like any field an upgrade
// drops.
if let (Some(applied), Some(parts)) = (applied, self.parts(version)) {
let pending = version == self.current() && !self.pending.is_empty();
if let Some(unknown) = applied.iter().find(|name| pending && !parts.iter().any(|p| p.name == name.as_str())) {
return Err(DocError::Invalid(format!(
"this design was saved by a newer SolidCraft (file format {version} with `{unknown}`, which this version doesn't know): update SolidCraft to open it"
)));
}
for part in parts.iter().filter(|p| !applied.iter().any(|name| name == p.name)) {
part.step.map_or(Ok(()), |step| step(v))?;
}
@@ -320,25 +426,26 @@ impl Formats<'_> {
}
}
/// Take a file's `format_pending` (the pending changes it has) out of its JSON.
fn take_pending(v: &mut Value) -> Result<Option<Vec<String>>> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove("format_pending")) else { return Ok(None) };
let invalid = || DocError::Invalid("damaged design: `format_pending` is not a short list of names".into());
/// Take a short list of names out of a file's JSON: its `format_pending` (the pending changes it
/// has).
fn take_names(v: &mut Value, key: &str, noun: &str) -> Result<Option<Vec<String>>> {
let Some(list) = v.as_object_mut().and_then(|o| o.remove(key)) else { return Ok(None) };
let invalid = || DocError::Invalid(format!("damaged {noun}: `{key}` is not a short list of names"));
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_>>().map(Some)
}
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
DESIGN.format_string(n)
}
/// The format tag of designs saved before the rename. Compatibility shim (docs/compatibility.md):
/// read, never written.
pub const LEGACY_FORMAT_PREFIX: &str = "solvecraft/";
/// The version in a format string (`solidcraft/N`, or the legacy `solvecraft/N`).
/// The version in a design's format string (`solidcraft/N`, or the legacy `solvecraft/N`).
pub fn version_of(s: &str) -> Option<u32> {
s.strip_prefix("solidcraft/").or_else(|| s.strip_prefix(LEGACY_FORMAT_PREFIX))?.parse().ok()
DESIGN.version_of(s)
}
/// JSON-level upgrades up to [`HAND_WRITTEN`]: entry `i` turns format `i + 1` into `i + 2`.
@@ -548,24 +655,10 @@ fn v2_to_v3(v: &mut Value) -> Result<()> {
/// Read a design file's JSON, upgrading older formats.
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 Some(obj) = v.as_object() else {
return Err(DocError::Invalid("not a SolidCraft design (expected a JSON object)".into()));
};
let version = match obj.get("format") {
None => 1,
Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolidCraft design (format `{f}`)")))?,
Some(_) => return Err(DocError::Invalid("not a SolidCraft design (format is not a string)".into())),
};
let applied = take_pending(&mut v)?;
if version == 0 {
return Err(DocError::Invalid("not a SolidCraft design (format 0)".into()));
}
if version > FORMAT {
return Err(DocError::Invalid(format!(
"this design was saved by a newer SolidCraft (file format {version}; this version reads formats up to {FORMAT}): update SolidCraft to open it"
)));
}
check_design_numbers(&v)?;
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)?;
DESIGN.check_numbers(&v, "metadata")?;
crate::config::validate_rules_json(&v)?;
crate::config::validate_styles_json(&v)?;
crate::face_anchor::validate_face_anchors_json(&v)?;
@@ -576,7 +669,7 @@ pub fn read(s: &str) -> Result<Document> {
crate::format_offsets::validate_raw(&v)?;
let legacy_wires = validate_wires(&v, version)?;
validate_body_transfers(&v)?;
FORMATS.upgrade(&mut v, version, applied.as_deref())?;
DESIGN.upgrade(&mut v, version, applied.as_deref())?;
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
for (feature, wire) in legacy_wires {
@@ -677,11 +770,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))
@@ -1036,31 +1140,21 @@ fn validate_freeform_offsets(value: &Value) -> Result<()> {
/// outside what the kernel's tolerances serve.
const MAX_REAL: f64 = 1e9;
/// Every number the design itself holds is in range ([`check_numbers`]). `metadata` is left
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
/// already bounds its nesting.
fn check_design_numbers(v: &Value) -> Result<()> {
match v {
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != "metadata").try_for_each(|(_, x)| check_numbers(x, 1)),
v => check_numbers(v, 0),
}
}
/// Every non-integer number in the file is finite and within ±`MAX_REAL` (a damaged or hostile
/// file is refused instead of overflowing the geometry later).
fn check_numbers(v: &Value, depth: usize) -> Result<()> {
/// Every non-integer number in a `noun`'s file is finite and within ±`MAX_REAL` (a damaged or
/// hostile file is refused instead of overflowing the geometry later).
fn check_numbers(v: &Value, noun: &str, depth: usize) -> Result<()> {
if depth > 64 {
return Err(DocError::Invalid("damaged design: nested too deeply".into()));
return Err(DocError::Invalid(format!("damaged {noun}: nested too deeply")));
}
match v {
Value::Number(n) if !(n.is_u64() || n.is_i64()) => {
let x = n.as_f64().unwrap_or(f64::NAN);
if !(x.is_finite() && x.abs() <= MAX_REAL) {
return Err(DocError::Invalid(format!("damaged design: the number {n} is out of range")));
return Err(DocError::Invalid(format!("damaged {noun}: the number {n} is out of range")));
}
}
Value::Array(a) => a.iter().try_for_each(|x| check_numbers(x, depth + 1))?,
Value::Object(m) => m.values().try_for_each(|x| check_numbers(x, depth + 1))?,
Value::Array(a) => a.iter().try_for_each(|x| check_numbers(x, noun, depth + 1))?,
Value::Object(m) => m.values().try_for_each(|x| check_numbers(x, noun, depth + 1))?,
_ => {}
}
Ok(())
@@ -1167,25 +1261,25 @@ mod tests {
assert_eq!(formats.current(), next);
for version in [1, HAND_WRITTEN, next - 1] {
let mut v = serde_json::json!({"features": [], "assembly": {"joints": [{"flip": false}]}});
formats.upgrade(&mut v, version, None).unwrap();
formats.upgrade("design", &mut v, version, None).unwrap();
assert_eq!(trace_of(&v), ["a", "b"], "from format {version}");
// The hand-written steps ran too: format 3's joints were flipped on the way up.
assert_eq!(v["assembly"]["joints"][0]["flip"], Value::Bool(version < 4), "from format {version}");
}
// A design of the pending number written before `b-second` existed gets just that step.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".to_string()])).unwrap();
formats.upgrade("design", &mut v, next, Some(&["a-first".to_string()])).unwrap();
assert_eq!(trace_of(&v), ["b"]);
// One that has both, or that this build wrote, needs nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
formats.upgrade("design", &mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
assert_eq!(trace_of(&v), Vec::<&str>::new());
// A change this build doesn't know is a newer build's: refused, not guessed at.
let err = formats.upgrade(&mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
let err = formats.upgrade("design", &mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
// A list on a format that isn't made of changes means nothing.
let mut v = serde_json::json!({});
formats.upgrade(&mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
formats.upgrade("design", &mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&v), ["a", "b"]);
}
@@ -1200,16 +1294,194 @@ mod tests {
let formats = Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] };
assert_eq!(formats.current(), next);
let mut old = serde_json::json!({});
formats.upgrade(&mut old, HAND_WRITTEN, None).unwrap();
formats.upgrade("design", &mut old, HAND_WRITTEN, None).unwrap();
assert_eq!(trace_of(&old), ["a", "b"]);
let mut nightly = serde_json::json!({});
formats.upgrade(&mut nightly, next, Some(&["a-first".into()])).unwrap();
formats.upgrade("design", &mut nightly, next, Some(&["a-first".into()])).unwrap();
assert_eq!(trace_of(&nightly), ["b"]);
let mut released = serde_json::json!({});
formats.upgrade(&mut released, next, None).unwrap();
formats.upgrade("design", &mut released, next, None).unwrap();
assert_eq!(trace_of(&released), Vec::<&str>::new());
}
/// A design naming a pending change this build doesn't have is a newer build's, refused as
/// such before anything else in it is checked (as a newer number is), so a newer build's
/// fields never read as damage.
#[test]
fn a_newer_builds_pending_change_is_refused_before_the_design_is_checked() {
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
file["features"] = serde_json::json!([{"type": "emboss", "face": {"body": "Body", "at": [0, 0, 0], "a_newer_key": true}}]);
let error = read(&file.to_string()).unwrap_err().to_string();
if PENDING.is_empty() {
assert!(error.contains("emboss face"), "a list beside a folded format means nothing: {error}");
} else {
assert_eq!(
error,
format!(
"this design was saved by a newer SolidCraft (file format {FORMAT} with `not-a-change-anyone-made`, which this version doesn't know): update SolidCraft to open it"
)
);
}
}
/// Drawings follow the same rules from their own first format: folded formats follow on from 1
/// (`v2/`, `v3/`, …), the next number is written while changes are pending, and the build lists
/// every change file in the drawing's folder, in file-name order.
#[test]
fn drawing_formats_follow_on_from_one_and_every_change_file_is_listed() {
for (i, (version, parts)) in DRAWING_FOLDED.iter().enumerate() {
assert_eq!(*version, 2 + i as u32);
assert!(!parts.is_empty(), "drawing format {version} has no changes");
}
assert_eq!(DRAWING.current(), 1 + DRAWING_FOLDED.len() as u32 + u32::from(!DRAWING_PENDING.is_empty()));
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/drawing/format");
let names = |dir: &str| -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(root.join(dir))
.map(|d| d.filter_map(|e| e.ok()?.file_name().to_str()?.strip_suffix(".rs").map(str::to_string)).collect())
.unwrap_or_default();
names.sort();
names
};
assert_eq!(names("pending"), DRAWING.pending_names());
for (version, parts) in DRAWING_FOLDED {
assert_eq!(names(&format!("v{version}")), parts.iter().map(|p| p.name).collect::<Vec<_>>());
}
}
/// A design's format tag never reads as a drawing's, nor a drawing's as a design's, at any
/// number: no family's tag starts another's, the legacy tag is a design's only, and each family
/// refuses the other's files by name.
#[test]
fn a_design_never_reads_as_a_drawing_or_the_other_way_round() {
let families = [&DESIGN, &DRAWING];
for a in families {
assert!(a.prefix.len() > 1 && a.prefix.ends_with('/'), "{}", a.prefix);
for b in families.into_iter().filter(|b| b.prefix != a.prefix) {
assert!(!a.prefix.starts_with(b.prefix), "{} starts {}", b.prefix, a.prefix);
}
}
for n in [0, 1, FORMAT, FORMAT + 1, DRAWING.current(), DRAWING.current() + 1, u32::MAX] {
assert_eq!(DESIGN.version_of(&DESIGN.format_string(n)), Some(n));
assert_eq!(DRAWING.version_of(&DRAWING.format_string(n)), Some(n));
assert_eq!(DRAWING.version_of(&DESIGN.format_string(n)), None, "{n}");
assert_eq!(DESIGN.version_of(&DRAWING.format_string(n)), None, "{n}");
assert_eq!(DRAWING.version_of(&format!("{LEGACY_FORMAT_PREFIX}{n}")), None, "{n}: the legacy tag is a design's");
}
for tag in [
"",
"solidcraft-drawing/",
"solidcraft-drawing",
"solidcraft-drawing/x",
"solidcraft-drawing/-1",
"solidcraft-drawing/4294967296",
"Solidcraft-drawing/1",
"solidcraft-drawing/1 ",
"solidcraft-drawings/1",
"solidcraft/drawing/1",
] {
assert_eq!(DRAWING.version_of(tag), None, "{tag:?}");
}
// The number reads as Rust reads a `u32`, a leading `+` included, as in a design's tag.
assert_eq!(DRAWING.version_of("solidcraft-drawing/+1"), Some(1));
let error = DRAWING.take_version(&mut serde_json::json!({"format": format_string(FORMAT)})).unwrap_err().to_string();
assert_eq!(error, format!("not a SolidCraft drawing (format `solidcraft/{FORMAT}`)"));
let error = read(&serde_json::json!({"format": DRAWING.format_string(1)}).to_string()).unwrap_err().to_string();
assert_eq!(error, "not a SolidCraft design (format `solidcraft-drawing/1`)");
}
/// Every refusal names its family: a design's read exactly as they did before drawings had one
/// (the troubleshooting guide quotes them), a drawing's say "drawing". A file that names no
/// format is a design from before format strings, never a drawing.
#[test]
fn refusals_name_the_family_and_a_designs_read_as_before() {
use serde_json::json;
fn refusal(family: &Family<'_>, mut file: Value) -> String {
family.take_version(&mut file).map_or_else(|e| e.to_string(), |opened| format!("read as {opened:?}"))
}
fn tagged(family: &Family<'_>, n: u32) -> Value {
serde_json::json!({"format": family.format_string(n)})
}
fn newer(noun: &str, n: u32, current: u32) -> String {
format!(
"this {noun} was saved by a newer SolidCraft (file format {n}; this version reads formats up to {current}): update SolidCraft to open it"
)
}
assert_eq!(refusal(&DESIGN, tagged(&DESIGN, FORMAT + 1)), newer("design", FORMAT + 1, FORMAT));
assert_eq!(read(&tagged(&DESIGN, FORMAT + 1).to_string()).unwrap_err().to_string(), newer("design", FORMAT + 1, FORMAT));
let current = DRAWING.current();
assert!(matches!(DRAWING.take_version(&mut tagged(&DRAWING, current)), Ok((v, None)) if v == current));
for n in [current + 1, u32::MAX] {
assert_eq!(refusal(&DRAWING, tagged(&DRAWING, n)), newer("drawing", n, current));
}
for (family, noun) in [(&DESIGN, "design"), (&DRAWING, "drawing")] {
assert_eq!(refusal(family, tagged(family, 0)), format!("not a SolidCraft {noun} (format 0)"));
assert_eq!(refusal(family, json!([])), format!("not a SolidCraft {noun} (expected a JSON object)"));
assert_eq!(refusal(family, json!({"format": 1})), format!("not a SolidCraft {noun} (format is not a string)"));
assert_eq!(refusal(family, json!({"format": ""})), format!("not a SolidCraft {noun} (format ``)"));
// `format_pending`'s caps, on both sides of each.
let mut file = tagged(family, 1);
file["format_pending"] = vec!["x".repeat(128); 1000].into();
assert!(family.take_version(&mut file).is_ok(), "{noun}: 1,000 names of 128 bytes");
for damaged in [vec!["x".repeat(128); 1001], vec!["x".repeat(129)]] {
let mut file = tagged(family, 1);
file["format_pending"] = damaged.into();
assert_eq!(refusal(family, file), format!("damaged {noun}: `format_pending` is not a short list of names"));
}
}
// A change this build doesn't know, in a design of the pending number.
let pending = [traced("a-first")];
let design = Family { formats: Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending }, ..DESIGN };
let next = design.current();
let file = json!({"format": format_string(next), "format_pending": ["c-later"]});
assert_eq!(
refusal(&design, file),
format!(
"this design was saved by a newer SolidCraft (file format {next} with `c-later`, which this version doesn't know): update SolidCraft to open it"
)
);
assert!(matches!(DESIGN.take_version(&mut json!({})), Ok((1, None))));
assert_eq!(refusal(&DRAWING, json!({})), "not a SolidCraft drawing (no format)");
}
/// One drawing change pending, as a drawing's first change will be: the build writes format 2
/// and names the change in `format_pending`. Its step runs for a drawing without the change and
/// never for one with it; a drawing naming a change this build lacks, or of format 3, is a newer
/// build's.
#[test]
fn a_pending_drawing_change_makes_format_two() {
use serde_json::json;
let pending = [traced("a-first")];
let drawing = Family { formats: Formats { hand: &[], folded: &[], pending: &pending }, ..DRAWING };
assert_eq!((drawing.current(), drawing.format_string(drawing.current())), (2, "solidcraft-drawing/2".to_string()));
assert_eq!(drawing.pending_names(), ["a-first"]);
let open = |mut file: Value| -> Result<Vec<String>> {
let (version, applied) = drawing.take_version(&mut file)?;
assert!(file.get("format_pending").is_none(), "taken out of the JSON before the record is read");
drawing.upgrade(&mut file, version, applied.as_deref())?;
Ok(trace_of(&file).into_iter().map(str::to_string).collect())
};
assert_eq!(open(json!({"format": "solidcraft-drawing/1"})).unwrap(), ["a"]);
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": []})).unwrap(), ["a"]);
assert_eq!(open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first"]})).unwrap(), Vec::<String>::new());
// A list beside format 1 means nothing: the step still runs.
assert_eq!(open(json!({"format": "solidcraft-drawing/1", "format_pending": ["c-later"]})).unwrap(), ["a"]);
let error = open(json!({"format": "solidcraft-drawing/2", "format_pending": ["a-first", "c-later"]})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 2 with `c-later`, which this version doesn't know): update SolidCraft to open it"
);
let error = open(json!({"format": "solidcraft-drawing/3"})).unwrap_err().to_string();
assert_eq!(
error,
"this drawing was saved by a newer SolidCraft (file format 3; this version reads formats up to 2): update SolidCraft to open it"
);
// An upgrade called without `take_version` refuses in the drawing's words too, format 0 included.
let error = drawing.upgrade(&mut json!({}), 3, None).unwrap_err().to_string();
assert!(error.starts_with("this drawing was saved by a newer SolidCraft (file format 3;"), "{error}");
assert_eq!(drawing.upgrade(&mut json!({}), 0, None).unwrap_err().to_string(), "not a SolidCraft drawing (format 0)");
}
/// `format_pending` is read and checked: a damaged list is refused, a list of changes this
/// build doesn't have is a newer build's, and saving writes this build's own list.
#[test]
@@ -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.
+195
View File
@@ -0,0 +1,195 @@
//! Object ids (#1017, #3890): random, so objects added side by side, on two branches of a
//! repository or in two copies of a file, never take the same id, and no counter changes with
//! every addition. Drawings use them from their first format; designs keep their numbered ids
//! until they move over too (#626 Phase 3).
//!
//! - In memory an id is a nonzero `u64`. 0 is never one (it stands for a design's root
//! component).
//! - An [`IdSource`] gives them: splitmix64 from a seed, each draw checked against the ids in
//! 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")]`.
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};
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;
/// Gives ids from a seed.
#[derive(Clone, Debug)]
pub struct IdSource {
state: u64,
}
impl IdSource {
/// The same ids for the same seed, for tests and replays.
pub fn seeded(seed: u64) -> IdSource {
IdSource { state: seed }
}
/// Ids another run gives only by chance: seeded from the standard library's hash keys (random
/// where the platform gives it randomness), the time, and a count of sources made, so two
/// sources made together differ.
pub fn from_entropy() -> IdSource {
static MADE: AtomicU64 = AtomicU64::new(0);
let nanos = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
IdSource::seeded(RandomState::new().hash_one((nanos, MADE.fetch_add(1, Ordering::Relaxed))))
}
/// 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> {
for _ in 0..in_use.len().saturating_add(2) {
let id = self.draw();
if id != 0 && !in_use.contains(&id) {
return Ok(id);
}
}
Err(DocError::Invalid("no free id".into()))
}
/// splitmix64: its mixing is one-to-one, so a run never gives one number twice.
fn draw(&mut self) -> u64 {
self.state = self.state.wrapping_add(GAMMA);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
}
/// `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::*;
fn none() -> BTreeSet<u64> {
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.
#[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}");
}
let mut source = IdSource::seeded(1);
for _ in 0..10_000 {
let id = source.next(&none()).unwrap();
assert_eq!(decode(&encode(id)), Some(id), "{id:#x}");
}
}
/// 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]
fn a_seeded_source_repeats_its_ids_and_never_gives_one_twice() {
let mut zero = IdSource::seeded(0);
assert_eq!([zero.draw(), zero.draw(), zero.draw()], [0xe220_a839_7b1d_cdaf, 0x6e78_9e6a_a1b9_65f4, 0x06c4_5d18_8009_454f]);
let run = |seed| {
let mut source = IdSource::seeded(seed);
(0..10_000).map(|_| source.next(&none()).unwrap()).collect::<Vec<_>>()
};
assert_eq!(run(7), run(7));
assert_ne!(run(7), run(8));
assert_eq!(run(7).into_iter().collect::<BTreeSet<_>>().len(), 10_000);
}
/// A draw that is in use, or 0, is drawn again: a file the same seed made, reopened with that
/// seed, collides on every one of its ids in turn and still gets a new one.
#[test]
fn a_taken_id_or_0_is_drawn_again() {
let mut seven = IdSource::seeded(7);
let made: Vec<u64> = (0..101).map(|_| seven.draw()).collect();
let in_use: BTreeSet<u64> = made.iter().take(100).copied().collect();
assert_eq!(IdSource::seeded(7).next(&in_use).unwrap(), made[100]);
// The state just before 0's: its first draw is 0, and the next is seed 0's first.
let before_zero = 0u64.wrapping_sub(GAMMA);
assert_eq!(IdSource::seeded(before_zero).draw(), 0);
assert_eq!(IdSource::seeded(before_zero).next(&none()).unwrap(), IdSource::seeded(0).draw());
}
/// Two sources made together still give different ids.
#[test]
fn sources_from_entropy_differ() {
let (mut a, mut b) = (IdSource::from_entropy(), IdSource::from_entropy());
assert_ne!(a.next(&none()).unwrap(), b.next(&none()).unwrap());
}
}
+4 -2
View File
@@ -19,6 +19,7 @@ pub mod construct;
pub mod display_quality;
pub mod display_units;
mod document;
pub mod drawing;
mod eval;
pub mod expr;
pub mod face_anchor;
@@ -28,6 +29,7 @@ pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
pub mod format;
mod format_offsets;
mod history_names;
pub mod ids;
pub use format_offsets::whole_profile_selection_from_value;
pub mod joints;
mod lineage;
@@ -53,8 +55,8 @@ pub use project3d::iso_grid;
pub mod sheet;
pub use assembly::{
IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse, mat_mul,
rigid,
BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse,
mat_mul, rigid,
};
pub use document::*;
pub use eval::KeptResults;
+43 -4
View File
@@ -3,7 +3,8 @@
//! SolidCraft reads a few well-known, unprefixed text keys from it (project, part number,
//! revision, author, organization, description, licence) and keeps every other key exactly as
//! it was through open, edit and save. Other tools keep their own data under one reverse-DNS
//! key each (`com.example.tool`), holding an object.
//! key each (`com.example.tool`), holding an object. A component's part properties follow the
//! same rules, with one more key: its bill of materials type (`bom_type`).
//!
//! 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
@@ -17,7 +18,7 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::{DocError, Result};
use crate::{BomType, DocError, Result};
pub const PROJECT: &str = "project";
pub const PART_NUMBER: &str = "part_number";
@@ -31,6 +32,11 @@ pub const LICENSE: &str = "license";
/// unprefixed key is reserved for SolidCraft too.
pub const WELL_KNOWN: [&str; 7] = [PROJECT, PART_NUMBER, REVISION, AUTHOR, ORGANIZATION, DESCRIPTION, LICENSE];
/// A component's [`BomType`] in the bill of materials (one of [`BomType::as_str`]'s words). It is
/// a part property of a component, not of the design, so the Design Properties dialog (which
/// shows [`WELL_KNOWN`]) doesn't list it.
pub const BOM_TYPE: &str = "bom_type";
/// Longest key, in bytes.
pub const MAX_KEY_BYTES: usize = 128;
/// Longest well-known text value, in bytes.
@@ -48,9 +54,9 @@ pub const MAX_TOTAL_BYTES: usize = 256 * 1024;
#[serde(transparent)]
pub struct Metadata(pub Map<String, Value>);
/// A key SolidCraft reads as a design property.
/// A key SolidCraft reads as a design or part property.
pub fn is_well_known(key: &str) -> bool {
WELL_KNOWN.contains(&key)
WELL_KNOWN.contains(&key) || key == BOM_TYPE
}
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
@@ -122,6 +128,11 @@ impl Metadata {
pub fn license(&self) -> Option<&str> {
self.text(LICENSE)
}
/// The component's bill of materials type: normal when the key is missing, or holds a word
/// this build doesn't know (a newer SolidCraft's type reads as normal and is kept as it is).
pub fn bom_type(&self) -> BomType {
self.text(BOM_TYPE).and_then(BomType::parse).unwrap_or_default()
}
/// The keys SolidCraft doesn't read (other tools' data, or keys a newer SolidCraft added),
/// kept as they are.
@@ -170,6 +181,11 @@ fn check(key: &str, value: Value) -> Result<Option<Value>> {
if s.chars().any(|c| c.is_control() && !matches!(c, '\n' | '\t' | '\r')) {
return Err(invalid(format!("`{key}` has a control character")));
}
if key == BOM_TYPE && BomType::parse(&s).is_none() {
let shown: String = s.chars().take(64).collect();
let words: Vec<&str> = BomType::ALL.iter().map(|t| t.as_str()).collect();
return Err(invalid(format!("`{BOM_TYPE}` is one of {}, not `{shown}`", words.join(", "))));
}
Ok(Some(Value::String(s)))
}
_ => Err(invalid(format!("`{key}` takes text"))),
@@ -243,6 +259,29 @@ mod tests {
assert_eq!(m.0.keys().collect::<Vec<_>>(), ["description"]);
}
#[test]
fn a_bom_type_takes_one_of_its_words_and_reads_as_normal_otherwise() {
let mut m = Metadata::default();
assert_eq!(m.bom_type(), BomType::Normal, "no key: normal");
for t in BomType::ALL {
assert!(m.set(BOM_TYPE, json!(t.as_str())).is_ok(), "{t:?}");
assert_eq!(m.bom_type(), t);
assert_eq!(BomType::parse(t.as_str()), Some(t));
}
for bad in [json!("Phantom"), json!("kit"), json!(" phantom"), json!(1), json!({"type": "phantom"}), json!("a\u{7}")] {
assert!(m.set(BOM_TYPE, bad.clone()).is_err(), "{bad}");
}
assert_eq!(m.bom_type(), BomType::Inseparable, "a refused value leaves the old one");
assert!(m.set(BOM_TYPE, json!("")).unwrap(), "empty text removes it");
assert_eq!(m.bom_type(), BomType::Normal);
assert!(m.others().next().is_none(), "SolidCraft reads it, so it isn't another tool's key");
// A word a newer build wrote is kept as it is and reads as normal here.
let newer: Metadata = serde_json::from_value(json!({"bom_type": "kit"})).unwrap();
assert_eq!(newer.bom_type(), BomType::Normal);
assert_eq!(newer.get(BOM_TYPE), Some(&json!("kit")));
assert!(!WELL_KNOWN.contains(&BOM_TYPE), "the Design Properties dialog doesn't list it");
}
#[test]
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
let mut m = Metadata::default();
+3 -18
View File
@@ -114,21 +114,6 @@ fn chorded_region(region: &Region2) -> Result<Region2> {
})
}
/// A line or free-form segment with its points mapped by `f`: exact under any affine map (a
/// conic keeps its weight). An arc is given by angles, not points, and stays as it is.
pub(crate) fn map_controls(seg: &Seg2, f: impl Fn(Vec2) -> Vec2) -> Seg2 {
match *seg {
Seg2::Line { a, b } => Seg2::Line { a: f(a), b: f(b) },
Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: f(p0), p1: f(p1), p2: f(p2), p3: f(p3) },
Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: f(a), apex: f(apex), b: f(b), w },
Seg2::Bezier { n, mut p } => {
p.iter_mut().take(usize::from(n) + 1).for_each(|q| *q = f(*q));
Seg2::Bezier { n, p }
}
arc @ Seg2::Arc { .. } => arc,
}
}
fn planar_region(region: &Region2) -> Result<Region2> {
if region.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
@@ -4017,7 +4002,7 @@ impl SheetBody {
}
}
// Free-form segments map exactly, through their control points.
other => map_controls(&other, map),
other => other.map_controls(map),
}
};
let map_loop = |lp: &Loop2| Loop2 { segs: lp.segs.iter().map(seg).collect() };
@@ -4378,7 +4363,7 @@ fn flip_x_loop(l: &Loop2) -> Loop2 {
Seg2::Arc { center, radius, start, sweep } => {
Seg2::Arc { center: flip_x(center), radius, start: std::f64::consts::PI - start, sweep: -sweep }
}
other => map_controls(&other, flip_x),
other => other.map_controls(flip_x),
})
.collect();
Loop2 { segs }.reversed()
@@ -5022,7 +5007,7 @@ mod tests {
// right: the same ellipse on the sheet, every curve through its mirrored controls.
let below = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y * -1.0 };
let mirror = |p: Vec2| Vec2::new(p.x, -p.y);
let seen = Region2 { outer: Loop2 { segs: hole.outer.segs.iter().map(|x| map_controls(x, mirror)).collect() }, holes: vec![] };
let seen = Region2 { outer: Loop2 { segs: hole.outer.segs.iter().map(|x| x.map_controls(mirror)).collect() }, holes: vec![] };
let mapped = s.region_to_flat(None, &below, &seen).unwrap();
assert!(mapped.outer.is_ccw() && mapped.outer.segs.len() == 4 && mapped.outer.segs.iter().all(|x| hole.outer.segs.contains(x)), "{mapped:?}");
// A cubic and a quartic closed by a line, clockwise in that plane: counter-clockwise on
+95 -38
View File
@@ -4,11 +4,17 @@
//! `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.
//!
//! 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};
use serde_json::{Map, Value, json};
use solidcraft_doc::metadata::{DESCRIPTION, MAX_KEYS, MAX_TEXT_BYTES, PART_NUMBER, REVISION, WELL_KNOWN};
use solidcraft_doc::BomType;
use solidcraft_doc::metadata::{BOM_TYPE, DESCRIPTION, MAX_KEYS, MAX_TEXT_BYTES, PART_NUMBER, REVISION, WELL_KNOWN};
use super::CommandSpec;
use crate::params::{bad, str_};
@@ -17,13 +23,14 @@ use crate::{EngineError, Result, Session};
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, \
metadata (as saved, other tools' keys too), properties: {name: text}",
bom_type (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); values?: {key: value} (SolidCraft's keys project, part_number, revision, author, organization, description, \
license, 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); values?: {key: value} \
(SolidCraft's keys project, part_number, revision, author, organization, description, license, 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]",
),
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 \
@@ -31,8 +38,10 @@ pub static COMMANDS: &[CommandSpec] = &[
(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) → rows: [{item, part_number, description, revision, count, definition, \
components, properties, children?}] sorted by part number, total_occurrences (children included)",
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)",
),
];
@@ -66,7 +75,7 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
let m = &c.metadata;
Ok(json!({"component": id, "name": c.name, "part_number": m.part_number(), "description": m.description(),
"revision": m.revision(), "metadata": m, "properties": c.properties}))
"revision": m.revision(), "bom_type": m.bom_type().as_str(), "metadata": m, "properties": c.properties}))
}
/// A free property as it may be saved (`None`: remove it), or why not.
@@ -107,7 +116,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 [PART_NUMBER, DESCRIPTION, REVISION] {
for k in [PART_NUMBER, DESCRIPTION, REVISION, BOM_TYPE] {
if let Some(v) = p.get(k) {
entries.push((k.to_string(), v.clone()));
}
@@ -119,7 +128,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 part_number, description, revision, values or properties ({})", WELL_KNOWN.join(", "))));
return Err(bad(
cmd,
format!("give part_number, description, revision, bom_type, values or properties ({}, {BOM_TYPE})", WELL_KNOWN.join(", ")),
));
}
let mut changed: Vec<String> = Vec::new();
for (k, v) in entries {
@@ -172,6 +184,7 @@ struct Row {
part_number: String,
description: String,
revision: String,
bom_type: BomType,
count: u64,
components: Vec<u64>,
names: Vec<String>,
@@ -221,6 +234,8 @@ struct Index {
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 {
@@ -230,8 +245,22 @@ impl Index {
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();
Index { children, owners: s.doc.body_owners(&s.model.state()), numbered }
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,
}
fn group_key(g: &str, cmd: &str) -> Result<Group> {
@@ -242,63 +271,88 @@ fn group_key(g: &str, cmd: &str) -> Result<Group> {
}
}
/// How many times each component is placed below `root`: in `Top`, its direct children; in
/// `Flat`, every placement at any depth (a part in a subassembly placed twice counts twice),
/// not below a numbered part when `fold`.
/// 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)> = vec![(root, 0)];
while let Some((comp, depth)) = stack.pop() {
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")));
}
*out.entry(*child).or_default() += 1;
if levels == Levels::Flat && !(fold && ix.numbered.contains(child)) {
stack.push((*child, depth + 1));
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));
}
}
}
}
}
if levels == Levels::Flat {
// Parts only: a subassembly is listed through its parts, unless it has bodies of its own
// (or, folding, a part number).
out.retain(|c, _| !ix.children.contains_key(c) || (fold && ix.numbered.contains(c)) || ix.owners.contains(c));
}
Ok(out)
}
/// How a kit reads when rows are compared: its parts by part number (else definition) and count.
fn kit_key(s: &Session, kit: &BTreeMap<u64, u64>) -> String {
/// 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();
if pn.is_empty() { format!("#{id}×{n}") } else { format!("{pn}×{n}") }
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, counts: &BTreeMap<u64, u64>, group: Group, kits: &BTreeMap<u64, BTreeMap<u64, u64>>) -> Vec<Row> {
let mut grouped: BTreeMap<(String, String, u64), Row> = BTreeMap::new();
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) are different rows.
// (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, &kit), 0),
_ => (String::new(), String::new(), *id),
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(),
@@ -357,10 +411,11 @@ fn bom(s: &mut Session, p: &Value) -> Result<Value> {
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.
// 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)) {
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);
@@ -368,22 +423,24 @@ fn bom(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let mut total: u64 = 0;
let rows: Vec<Value> = rows(s, &counts, o.group, &kits)
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,
"count": r.count, "definition": r.names.join("; "), "components": r.components, "properties": r.properties});
"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, &r.kit, Group::PartNumber, &BTreeMap::new())
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, "each": c.count, "count": count, "definition": c.names.join("; ")})
"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);
+224
View File
@@ -275,3 +275,227 @@ fn body_owners_match_each_bodys_component() {
assert!(each.len() > 1);
}
}
/// A machine: a plate, and a gearbox (no bodies of its own) that holds a shaft and a bearing.
/// Each is placed once.
fn machine() -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10}));
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.create", json!({"name": "Gearbox", "activate": false}));
for (name, diameter, height) in [("Shaft", 10, 80), ("Bearing", 22, 7)] {
run(&mut s, "component.create", json!({"name": name, "parent": "Gearbox"}));
run(&mut s, "solid.cylinder", json!({"diameter": diameter, "height": height}));
run(&mut s, "component.activate", json!({"component": 0}));
}
for (c, pn) in [("Plate", "PL-1"), ("Gearbox", "GB-1"), ("Shaft", "SH-1"), ("Bearing", "BR-1")] {
run(&mut s, "component.metadata.set", json!({"component": c, "part_number": pn}));
}
s
}
/// Each row's part number, bill of materials type and count.
fn typed(r: &Value) -> Vec<(String, String, u64)> {
r["rows"]
.as_array()
.unwrap()
.iter()
.map(|r| (r["part_number"].as_str().unwrap().to_string(), r["bom_type"].as_str().unwrap().to_string(), r["count"].as_u64().unwrap()))
.collect()
}
fn row(part_number: &str, bom_type: &str, count: u64) -> (String, String, u64) {
(part_number.to_string(), bom_type.to_string(), count)
}
fn set_type(s: &mut Session, component: &str, bom_type: &str) {
run(s, "component.metadata.set", json!({"component": component, "bom_type": bom_type}));
}
#[test]
fn bom_types_decide_what_is_a_row_and_what_counts_inside_it() {
let mut s = machine();
let top = |s: &mut Session| typed(&run(s, "assembly.bom", json!({"levels": "top"})));
let flat = |s: &mut Session| typed(&run(s, "assembly.bom", json!({})));
let own = |s: &mut Session| typed(&run(s, "assembly.bom", json!({"component": "Gearbox"})));
// Normal: the gearbox is a row at the top and is listed through its parts in parts only.
assert_eq!(top(&mut s), [row("GB-1", "normal", 1), row("PL-1", "normal", 1)]);
assert_eq!(flat(&mut s), [row("BR-1", "normal", 1), row("PL-1", "normal", 1), row("SH-1", "normal", 1)]);
// Purchased: one row, with nothing inside it listed; its own bill of materials still lists it.
set_type(&mut s, "Gearbox", "purchased");
assert_eq!(top(&mut s), [row("GB-1", "purchased", 1), row("PL-1", "normal", 1)]);
assert_eq!(flat(&mut s), [row("GB-1", "purchased", 1), row("PL-1", "normal", 1)]);
assert_eq!(own(&mut s), [row("BR-1", "normal", 1), row("SH-1", "normal", 1)]);
// Inseparable: one row; of what it holds only purchased parts reach the parts-only list.
set_type(&mut s, "Gearbox", "inseparable");
assert_eq!(flat(&mut s), [row("GB-1", "inseparable", 1), row("PL-1", "normal", 1)]);
set_type(&mut s, "Bearing", "purchased");
assert_eq!(top(&mut s), [row("GB-1", "inseparable", 1), row("PL-1", "normal", 1)]);
assert_eq!(flat(&mut s), [row("BR-1", "purchased", 1), row("GB-1", "inseparable", 1), row("PL-1", "normal", 1)]);
assert_eq!(own(&mut s), [row("BR-1", "purchased", 1), row("SH-1", "normal", 1)], "in its own context an ordinary assembly");
// Reference: counted nowhere, and nothing inside it is counted, purchased parts included.
set_type(&mut s, "Gearbox", "reference");
assert_eq!(top(&mut s), [row("PL-1", "normal", 1)]);
assert_eq!(flat(&mut s), [row("PL-1", "normal", 1)]);
assert_eq!(run(&mut s, "assembly.bom", json!({}))["total_occurrences"], 1);
// Phantom: no row; what it holds counts where it is placed, at the top level too.
set_type(&mut s, "Gearbox", "phantom");
assert_eq!(top(&mut s), [row("BR-1", "purchased", 1), row("PL-1", "normal", 1), row("SH-1", "normal", 1)]);
assert_eq!(flat(&mut s), [row("BR-1", "purchased", 1), row("PL-1", "normal", 1), row("SH-1", "normal", 1)]);
// Back to normal (empty text removes the key) gives the first lists again.
set_type(&mut s, "Gearbox", "");
set_type(&mut s, "Bearing", "normal");
assert_eq!(top(&mut s), [row("GB-1", "normal", 1), row("PL-1", "normal", 1)]);
assert_eq!(flat(&mut s), [row("BR-1", "normal", 1), row("PL-1", "normal", 1), row("SH-1", "normal", 1)]);
assert_eq!(run(&mut s, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "normal");
}
#[test]
fn a_phantom_counts_what_it_holds_in_its_parent_merged_with_the_same_parts() {
// A fastener set (no bodies) of two screws, placed three times, and one more screw placed directly.
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Set", "activate": false}));
run(&mut s, "component.create", json!({"name": "Screw", "parent": "Set"}));
run(&mut s, "solid.cylinder", json!({"diameter": 6, "height": 20}));
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "occurrence.copy", json!({"component": "Screw", "parent": "Set", "translate": [10, 0, 0]}));
for y in [50, 100] {
run(&mut s, "occurrence.copy", json!({"component": "Set", "translate": [0, y, 0]}));
}
run(&mut s, "occurrence.copy", json!({"component": "Screw", "parent": 0, "translate": [0, -50, 0]}));
run(&mut s, "component.metadata.set", json!({"component": "Screw", "part_number": "SC-6"}));
run(&mut s, "component.metadata.set", json!({"component": "Set", "part_number": "SET-1"}));
assert_eq!(typed(&run(&mut s, "assembly.bom", json!({"levels": "top"}))), [row("SC-6", "normal", 1), row("SET-1", "normal", 3)]);
assert_eq!(typed(&run(&mut s, "assembly.bom", json!({}))), [row("SC-6", "normal", 7)], "2 x 3 + 1");
set_type(&mut s, "Set", "phantom");
assert_eq!(typed(&run(&mut s, "assembly.bom", json!({"levels": "top"}))), [row("SC-6", "normal", 7)], "promoted and merged");
let flat = run(&mut s, "assembly.bom", json!({}));
assert_eq!(typed(&flat), [row("SC-6", "normal", 7)]);
assert_eq!(flat["total_occurrences"], 7);
// A phantom inside a phantom promotes through both.
run(&mut s, "component.create", json!({"name": "Kit", "activate": false}));
run(&mut s, "occurrence.copy", json!({"component": "Set", "parent": "Kit"}));
run(&mut s, "occurrence.copy", json!({"component": "Kit", "translate": [0, 200, 0]}));
set_type(&mut s, "Kit", "phantom");
// The Kit created at the root holds one Set; its copy holds the same: 2 kits x 2 screws more.
assert_eq!(typed(&run(&mut s, "assembly.bom", json!({"levels": "top"}))), [row("SC-6", "normal", 11)]);
}
/// Kits with one part number fold into one row only when what they hold is the same, its bill of
/// materials types included: a kit holding a bought bolt isn't the kit holding a made one.
#[test]
fn kits_with_one_part_number_fold_together_only_when_their_contents_types_match() {
let mut s = Session::default();
for (kit, bolt) in [("KitA", "BoltA"), ("KitB", "BoltB")] {
run(&mut s, "component.create", json!({"name": kit, "activate": false}));
run(&mut s, "component.create", json!({"name": bolt, "parent": kit}));
run(&mut s, "solid.cylinder", json!({"diameter": 6, "height": 20}));
run(&mut s, "component.activate", json!({"component": 0}));
run(&mut s, "component.metadata.set", json!({"component": kit, "part_number": "KIT"}));
run(&mut s, "component.metadata.set", json!({"component": bolt, "part_number": "BOLT"}));
}
// Each kit row: its count, and each child's definition, count in one kit and count in all.
let kits = |s: &mut Session| -> Vec<(u64, Vec<(String, u64, u64)>)> {
let r = run(s, "assembly.bom", json!({"group_children": true}));
let child = |c: &Value| (c["definition"].as_str().unwrap().to_string(), c["each"].as_u64().unwrap(), c["count"].as_u64().unwrap());
r["rows"]
.as_array()
.unwrap()
.iter()
.map(|k| (k["count"].as_u64().unwrap(), k["children"].as_array().unwrap().iter().map(child).collect()))
.collect()
};
let same = kits(&mut s);
assert_eq!((same.len(), same[0].0, same[0].1.len(), same[0].1[0].1, same[0].1[0].2), (1, 2, 1, 1, 2), "{same:?}");
set_type(&mut s, "BoltB", "purchased");
assert_eq!(kits(&mut s), [(1, vec![("BoltA".into(), 1, 1)]), (1, vec![("BoltB".into(), 1, 1)])]);
}
#[test]
fn a_purchased_part_keeps_what_is_placed_inside_it_out_of_the_list() {
let mut s = bolts();
// Each inserted bolt is a definition of its own; buy them all.
let bolts = run(&mut s, "assembly.bom", json!({}))["rows"][0]["components"].clone();
assert_eq!(bolts.as_array().map(Vec::len), Some(3));
for bolt in bolts.as_array().unwrap() {
run(&mut s, "component.metadata.set", json!({"component": bolt, "bom_type": "purchased"}));
}
// The washers and nuts placed inside two of them are no longer listed.
let plain = run(&mut s, "assembly.bom", json!({}));
assert_eq!(typed(&plain), [row("ISO 4017 M6x30", "purchased", 3)], "{plain}");
assert_eq!(plain["total_occurrences"], 3);
// Folding has nothing to fold: a purchased part is one row already.
let folded = run(&mut s, "assembly.bom", json!({"group_children": true}));
assert_eq!(typed(&folded), [row("ISO 4017 M6x30", "purchased", 3)]);
assert!(folded["rows"][0].get("children").is_none(), "{folded}");
// One bolt bought and two made gives two rows for the one part number.
run(&mut s, "component.metadata.set", json!({"component": bolts[0], "bom_type": null}));
run(&mut s, "component.metadata.set", json!({"component": bolts[1], "bom_type": null}));
let mixed = typed(&run(&mut s, "assembly.bom", json!({})));
assert!(mixed.contains(&row("ISO 4017 M6x30", "normal", 2)) && mixed.contains(&row("ISO 4017 M6x30", "purchased", 1)), "{mixed:?}");
}
#[test]
fn a_bom_type_is_one_undo_step_survives_save_and_open_and_evaluates_nothing() {
let dir = scratch("bom-type");
let mut s = machine();
let undo = s.undo.len();
s.evaluations = 0;
let r = run(&mut s, "component.metadata.set", json!({"component": "Gearbox", "bom_type": "inseparable"}));
assert_eq!(r["changed"], json!(["bom_type"]));
assert_eq!(s.undo.len(), undo + 1);
assert_eq!(s.evaluations, 0, "a part property changes no geometry");
for (file, format) in [("machine.json", "json"), ("machine.solidcraft", "container")] {
let path = dir.join(file);
run(&mut s, "file.save_as", json!({"path": path, "format": format}));
let mut back = Session::default();
run(&mut back, "doc.open", json!({"path": path}));
assert_eq!(run(&mut back, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "inseparable", "{format}");
assert_eq!(typed(&run(&mut back, "assembly.bom", json!({}))), typed(&run(&mut s, "assembly.bom", json!({}))), "{format}");
}
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "normal");
// Through `values` too, and the same word again changes nothing.
run(&mut s, "component.metadata.set", json!({"component": "Gearbox", "values": {"bom_type": "phantom"}}));
let again = run(&mut s, "component.metadata.set", json!({"component": "Gearbox", "bom_type": "phantom"}));
assert_eq!(again["changed"], json!([]));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hostile_bom_types_are_refused_without_a_change() {
let mut s = machine();
let undo = s.undo.len();
for p in [
json!({"component": "Gearbox", "bom_type": "virtual"}),
json!({"component": "Gearbox", "bom_type": "Phantom"}),
json!({"component": "Gearbox", "bom_type": 3}),
json!({"component": "Gearbox", "bom_type": ["phantom"]}),
json!({"component": "Gearbox", "bom_type": "x".repeat(5000)}),
json!({"component": "Gearbox", "values": {"bom_type": "phantom\u{7}"}}),
json!({"component": 0, "bom_type": "phantom"}),
json!({"component": "Gearbox", "part_number": "GB-2", "bom_type": "nope"}),
] {
assert!(s.execute("component.metadata.set", &p).is_err(), "{p}");
}
assert!(s.execute("document.metadata.set", &json!({"key": "bom_type", "value": "phantom"})).is_err(), "not a design property");
assert!(s.execute("document.metadata.set", &json!({"values": {"bom_type": "purchased"}})).is_err());
assert_eq!(s.undo.len(), undo, "nothing changed");
let gb = run(&mut s, "component.metadata", json!({"component": "Gearbox"}));
assert_eq!((gb["part_number"].as_str(), gb["bom_type"].as_str()), (Some("GB-1"), Some("normal")), "all or nothing");
// A word another build wrote is kept and reads as normal.
let id = s.doc.find_component("Gearbox").unwrap();
if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == id) {
c.metadata.0.insert("bom_type".into(), json!("kit"));
}
assert_eq!(run(&mut s, "component.metadata", json!({"component": "Gearbox"}))["bom_type"], "normal");
assert_eq!(typed(&run(&mut s, "assembly.bom", json!({"levels": "top"}))), [row("GB-1", "normal", 1), row("PL-1", "normal", 1)]);
}
+1
View File
@@ -682,6 +682,7 @@ fn redefine_sketch(s: &mut Session, p: &Value) -> Result<Value> {
&& super::sketch::face::anchor(&plane)
.is_none_or(|next| next.target != anchor.target || next.component != anchor.component || next.path != anchor.path)
{
// design-only: Redefine Sketch Plane edits the design's sketch feature.
let sketch = trial.doc_mut().sketch_mut(id)?;
if let Some(mut visible) = visible_geometry {
let constraints: std::collections::BTreeMap<_, _> =
+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",
+218
View File
@@ -0,0 +1,218 @@
//! 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::BTreeSet;
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_doc::drawing::{self, Drawing, Source, Units};
use solidcraft_doc::ids::{self, IdSource};
use solidcraft_geom::drawing_sheet::{DrawingBorderProfile, Orientation, PaperSize, Sheet};
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\". 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, language, properties, sources: [{id}], sheets: [{id, name, size_mm, orientation, dpi, documents, excluded}]}. 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\". 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\"")),
}
}
/// 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 mut id_source = IdSource::from_entropy();
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 id_source).map_err(fail)?;
let source = d.allocate_id(&mut id_source).map_err(fail)?;
d.sources.push(Source { id: source });
if let Some(units) = units(cmd, p)? {
d.units = units;
}
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)}))
}
/// 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)?;
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,
}));
}
Ok(json!({
"id": ids::encode(d.id),
"name": d.name,
"units": d.units,
"language": d.language,
"properties": d.properties,
"sources": d.sources.iter().map(|source| json!({"id": ids::encode(source.id)})).collect::<Vec<_>>(),
"sheets": sheets,
}))
}
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;
}
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 = 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;
+187
View File
@@ -0,0 +1,187 @@
//! Sheet-owned sketch lifecycle on the embedded drawing receiver (W38a).
//! Geometry tools reuse cmd::sketch and cmd::sketch_store; there is no second sketcher.
use super::{CommandSpec, sketch_store};
use crate::params::bad;
use crate::schema::{drawing_sketch as contract, output_schema_for};
use crate::{Result, Session, SheetSketchEdit};
use serde::de::DeserializeOwned;
use serde_json::Value;
use solidcraft_doc::drawing::SheetSketch;
use solidcraft_doc::ids::{self, IdSource};
use solidcraft_sketch::Sketch;
use std::sync::Arc;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("drawing.sketch.create", "Create Sheet Sketch", crate::checked!("drawing.sketch.create", contract::CREATE, create))
.in_drawing().doc_only().icon("sketch")
.contract(&contract::CREATE).output(output_schema_for::<contract::Reading>)
.params("owner: exact sheet id in the open drawing; name?: text. Creates and enters one sheet sketch in paper XY millimetres; one drawing undo step."),
CommandSpec::new("drawing.sketch.edit", "Edit Sheet Sketch", crate::checked!("drawing.sketch.edit", contract::EDIT, edit))
.in_drawing().doc_only().noundo().icon("sketch")
.contract(&contract::EDIT).output(output_schema_for::<contract::Reading>)
.params("sketch: exact encoded sketch id in the open drawing. Enters that sheet sketch without changing its data or the source design."),
CommandSpec::new("drawing.sketch.inspect", "Inspect Sheet Sketch", crate::checked!("drawing.sketch.inspect", contract::INSPECT, inspect))
.in_drawing().doc_only().noundo().icon("inspect")
.contract(&contract::INSPECT).output(output_schema_for::<contract::Reading>)
.params("sketch?: exact encoded id, else the active sheet sketch. Reports resolved paper-mm points and local entity ids without adopting the solution."),
];
fn parsed<T: DeserializeOwned>(cmd: &str, p: &Value) -> Result<T> {
T::deserialize(p).map_err(|e| bad(cmd, e.to_string()))
}
fn id(cmd: &str, text: &str) -> Result<u64> {
ids::decode(text).ok_or_else(|| bad(cmd, "an exact encoded drawing object id is required"))
}
fn no_entry(s: &Session, cmd: &str) -> Result<()> {
if s.active_sheet_sketch.is_some() {
return Err(bad(cmd, "finish or cancel the sheet sketch before entering another"));
}
Ok(())
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "drawing.sketch.create";
let p: contract::Create = parsed(cmd, p)?;
no_entry(s, cmd)?;
let owner = s.drawing(cmd)?.clone();
owner.validate().map_err(|e| bad(cmd, e.to_string()))?;
let sheet = id(cmd, &p.owner)?;
if !owner.sheets.iter().any(|record| record.id == sheet) {
return Err(bad(cmd, "the open drawing has no such sheet"));
}
let name = p.name.unwrap_or_else(|| "Sheet sketch".into());
// Any object id held by drawing undo/redo must not name a new sketch either.
let retired: std::collections::BTreeSet<u64> = s
.undo
.iter()
.chain(&s.redo)
.filter_map(|step| step.doc.drawing(owner.id))
.flat_map(|drawing| {
std::iter::once(drawing.id)
.chain(drawing.sources.iter().map(|record| record.id))
.chain(drawing.sheets.iter().map(|record| record.id))
.chain(drawing.sketches.iter().map(|record| record.id))
})
.collect();
let mut source = IdSource::from_entropy();
let mut fresh = None;
for _ in 0..64 {
let next = owner.allocate_id(&mut source)?;
if !retired.contains(&next) {
fresh = Some(next);
break;
}
}
let sketch = fresh.ok_or_else(|| bad(cmd, "could not allocate an unused sheet sketch id"))?;
s.edit_drawing(cmd, |drawing| {
drawing.sketches.push(Arc::new(SheetSketch { id: sketch, sheet, name, sketch: Sketch::new() }));
Ok(())
})?;
s.active_sheet_sketch = Some(Arc::new(SheetSketchEdit { drawing: owner.id, sheet, sketch, original: None }));
s.selection.clear();
reading(s, sketch, cmd)
}
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "drawing.sketch.edit";
let p: contract::Edit = parsed(cmd, p)?;
let sketch = id(cmd, &p.sketch)?;
let owner = s.drawing(cmd)?.clone();
if let Some(entry) = &s.active_sheet_sketch {
if entry.drawing == owner.id && entry.sketch == sketch {
return reading(s, sketch, cmd); // keep the original cancel boundary
}
return Err(bad(cmd, "finish or cancel the sheet sketch before entering another"));
}
let solved = s.doc.solve_sheet_sketch(owner.id, sketch)?;
let original = owner.sheet_sketch(sketch).cloned().ok_or_else(|| bad(cmd, "the open drawing has no such sketch"))?;
s.active_sheet_sketch = Some(Arc::new(SheetSketchEdit { drawing: owner.id, sheet: solved.sheet, sketch, original: Some(original) }));
s.selection.clear();
s.revision += 1;
reading(s, sketch, cmd)
}
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "drawing.sketch.inspect";
let p: contract::Inspect = parsed(cmd, p)?;
let sketch = match p.sketch {
Some(text) => id(cmd, &text)?,
None => sketch_store::sheet_target(s, &Value::Null, cmd)?.sketch,
};
reading(s, sketch, cmd)
}
fn reading(s: &Session, sketch: u64, cmd: &str) -> Result<Value> {
let owner = s.drawing(cmd)?;
let solved = s.doc.solve_sheet_sketch(owner.id, sketch)?;
let record = owner.sheet_sketch(sketch).ok_or_else(|| bad(cmd, "the open drawing has no such sketch"))?;
let reading = contract::Reading {
drawing: ids::encode(owner.id),
sheet: ids::encode(solved.sheet),
sketch: ids::encode(sketch),
name: record.name.clone(),
editing: s
.active_sheet_sketch
.as_ref()
.is_some_and(|active| active.drawing == owner.id && active.sheet == solved.sheet && active.sketch == sketch),
solved: solved.report.keeps_orientation(),
dof: solved.report.dof,
points: solved
.sketch
.points
.iter()
.map(|point| contract::Point { id: point.id.clone(), at: [point.pos.x, point.pos.y], fixed: point.fixed })
.collect(),
curves: solved.sketch.curves.iter().map(|curve| curve.id.clone()).collect(),
constraints: solved.sketch.constraints.iter().map(|constraint| constraint.id.clone()).collect(),
};
serde_json::to_value(reading).map_err(|e| bad(cmd, e.to_string()))
}
pub(super) fn finish(s: &mut Session, p: &Value) -> Result<Value> {
let _: contract::Empty = parsed("sketch.finish", p)?;
let entry = sketch_store::sheet_target(s, &Value::Null, "sketch.finish")?;
let mut result = reading(s, entry.sketch, "sketch.finish")?;
s.active_sheet_sketch = None;
s.selection.clear();
s.revision += 1;
if let Some(object) = result.as_object_mut() {
object.insert("editing".into(), Value::Bool(false));
}
Ok(result)
}
pub(super) fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.cancel";
let _: contract::Empty = parsed(cmd, p)?;
let entry = sketch_store::sheet_target(s, &Value::Null, cmd)?;
let removed = entry.original.is_none();
s.edit_drawing(cmd, |drawing| {
let at = drawing
.sketches
.iter()
.position(|record| record.id == entry.sketch)
.ok_or_else(|| bad(cmd, "the edited sheet sketch no longer exists"))?;
match &entry.original {
Some(original) => {
let slot = drawing.sketches.get_mut(at).ok_or_else(|| bad(cmd, "the sheet sketch changed while cancelling"))?;
*slot = original.clone();
}
None => {
drawing.sketches.remove(at);
}
}
Ok(())
})?;
s.active_sheet_sketch = None;
s.selection.clear();
s.revision += 1;
serde_json::to_value(contract::Cancelled {
drawing: ids::encode(entry.drawing),
sheet: ids::encode(entry.sheet),
sketch: ids::encode(entry.sketch),
removed,
})
.map_err(|e| bad(cmd, e.to_string()))
}
File diff suppressed because it is too large Load Diff
+68 -14
View File
@@ -1,22 +1,24 @@
//! Undo/redo, the timeline, deletion and selection.
use std::collections::BTreeMap;
use std::sync::Arc;
use serde_json::{Value, json};
use solidcraft_doc::{Feature, FeatureKind};
use solidcraft_doc::drawing::{self, Drawing};
use solidcraft_doc::{Document, Feature, FeatureKind};
use super::CommandSpec;
use crate::params::{bad, bool_, num, str_, string_list};
use crate::{EngineError, Result, Sel, Session, Snapshot};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint).icon("checkpoint")
CommandSpec::new("edit.undo", "Undo", undo).any_part().icon("undo").key("Ctrl+Z").noundo().doc_only(),
CommandSpec::new("edit.redo", "Redo", redo).any_part().icon("redo").key("Ctrl+Y").noundo().doc_only(),
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint).any_part().icon("checkpoint")
.noundo()
.doc_only()
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint).icon("restore")
CommandSpec::new("edit.restore_checkpoint", "Restore Checkpoint", restore_checkpoint).any_part().icon("restore")
.noundo()
.doc_only()
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included"),
@@ -68,14 +70,14 @@ pub static COMMANDS: &[CommandSpec] = &[
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
),
CommandSpec::new("select.set", "Select", select_set).icon("select")
CommandSpec::new("select.set", "Select", select_set).any_part().icon("select")
.noundo()
.params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"),
CommandSpec::new("select.clear", "Clear Selection", select_clear).icon("select").noundo(),
CommandSpec::new("select.clear", "Clear Selection", select_clear).any_part().icon("select").noundo(),
];
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
let prev = latest(&mut s.undo, s.open_drawing).ok_or_else(|| nothing(s, "undo"))?;
let now = go_to(s, prev);
let label = now.label.clone();
s.redo.push(now);
@@ -83,28 +85,80 @@ fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
}
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
let next = latest(&mut s.redo, s.open_drawing).ok_or_else(|| nothing(s, "redo"))?;
let now = go_to(s, next);
let label = now.label.clone();
s.undo.push(now);
Ok(json!({"redone": label}))
}
/// Put the session at `snap`; returns where it was, under the same label.
/// Takes the latest of `steps` on `part` (the open drawing, else the model): undo and redo go
/// through one part's steps, and leave the others' where they are.
fn latest(steps: &mut Vec<Snapshot>, part: Option<u64>) -> Option<Snapshot> {
let at = steps.iter().rposition(|step| step.part == part)?;
Some(steps.remove(at))
}
fn nothing(s: &Session, what: &str) -> EngineError {
match s.open_drawing {
Some(_) => EngineError::Other(format!("nothing to {what} in this drawing (close it to {what} in the model)")),
None => EngineError::Other(format!("nothing to {what}")),
}
}
/// Put the session at `snap`; returns where it was, under the same label. A drawing's step puts
/// back only that drawing. The model's step puts back the design as it was, but keeps each
/// drawing the design had as it is now: each part's steps are undone in that part only. Neither
/// opens or closes a drawing: a drawing's steps are taken while it is open, and the model's while
/// none is.
fn go_to(s: &mut Session, snap: Snapshot) -> Snapshot {
let was = Snapshot { label: snap.label, doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() };
let same = std::sync::Arc::ptr_eq(&s.doc, &snap.doc);
let was = Snapshot { part: snap.part, ..Snapshot::of(s, &snap.label) };
let snap = match snap.part {
Some(id) => Snapshot { doc: drawing_from(&s.doc, &snap.doc, id), active_sheet_sketch: snap.active_sheet_sketch.clone(), ..was.clone() },
None => Snapshot { doc: drawings_from(snap.doc, &s.doc), ..snap },
};
let same = Arc::ptr_eq(&s.doc, &snap.doc);
let moved = s.active_sketch != snap.active_sketch || s.pending_moves != snap.pending_moves;
s.doc = snap.doc;
s.active_sketch = snap.active_sketch;
s.active_sheet_sketch = snap.active_sheet_sketch;
s.prune_sheet_sketch_edit();
s.pending_moves = snap.pending_moves;
s.selection.clear();
// A new document is evaluated by `execute`.
if same {
// A new document is evaluated by `execute`, and a drawing's change evaluates nothing.
if same && moved {
s.refresh();
}
was
}
/// `doc` with its drawing `id` as `step` has it.
fn drawing_from(doc: &Arc<Document>, step: &Document, id: u64) -> Arc<Document> {
let mut doc = doc.clone();
if let Some(then) = step.drawing(id)
&& !doc.drawing(id).is_some_and(|now| Arc::ptr_eq(then, now))
{
Arc::make_mut(&mut doc).put_drawing(then.clone());
}
doc
}
/// The model's step `step` with each of its drawings as `now` has it.
fn drawings_from(mut step: Arc<Document>, now: &Document) -> Arc<Document> {
let current = |d: &Arc<Drawing>| now.drawing(d.id).filter(|n| !Arc::ptr_eq(d, n));
if step.drawings.iter().any(|d| current(d).is_some()) {
let mut drawings = step.drawings.clone();
for d in &mut drawings {
if let Some(n) = current(d) {
*d = n.clone();
}
}
drawing::sort(&mut drawings);
Arc::make_mut(&mut step).drawings = drawings;
}
step
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"checkpoint": s.checkpoint()}))
}
+1 -1
View File
@@ -262,7 +262,7 @@ pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
_ => Err(bad(cmd, format!("`{key}` is not a sketch"))),
};
}
if let Some(a) = s.active_sketch {
if let Some(a) = super::sketch_store::active(s) {
return Ok(a);
}
let limit = s.doc.marker.unwrap_or(usize::MAX);
+7 -7
View File
@@ -15,8 +15,8 @@ use crate::params::{bad, bool_, num, str_, string_list};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.new", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open)
CommandSpec::new("file.new", "New Design", new_doc).any_part().icon("new").key("Ctrl+N").noundo().params("name?"),
CommandSpec::new("doc.open", "Open", open).any_part()
.icon("open")
.key("Ctrl+O")
.noundo()
@@ -29,22 +29,22 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "INSERT")
.icon("import")
.params("path: .3mf, .stl, .obj, .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept)"),
CommandSpec::new("file.save", "Save", save)
CommandSpec::new("file.save", "Save", save).any_part()
.icon("save")
.key("Ctrl+S")
.noundo()
.params("path? (default: current file); format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false)"),
CommandSpec::new("file.save_as", "Save As", save_as)
CommandSpec::new("file.save_as", "Save As", save_as).any_part()
.icon("save")
.noundo()
.params("path; format?: container|json (default: json for .json, otherwise container); compression?: fast|compact (default: fast); cache?: bool (the evaluated model, so the design opens without rebuilding; default: true); preview?: bool (saved display meshes, default: false)"),
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).icon("restore")
CommandSpec::new("doc.recovery_list", "Recoverable Designs", recovery_list).any_part().icon("restore")
.noundo()
.params("dir? (default: the recovery folder) → designs autosaved by apps that crashed or closed with unsaved changes"),
CommandSpec::new("doc.recover", "Recover Design", recover).icon("restore")
CommandSpec::new("doc.recover", "Recover Design", recover).any_part().icon("restore")
.noundo()
.params("id (from doc.recovery_list); dir?; discard?: bool (delete the entry once open) — opens it with its file path, unsaved"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).icon("delete").noundo().params("id, or all: true; dir?"),
CommandSpec::new("doc.recovery_discard", "Discard Recovered Design", recovery_discard).any_part().icon("delete").noundo().params("id, or all: true; dir?"),
CommandSpec::new("file.export", "Export", export)
.icon("export")
.noundo()
+3 -8
View File
@@ -43,8 +43,8 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.doc_only()
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", DESCRIBE_CONTRACT, describe))
CommandSpec::new("engine.commands", "List Commands", commands).any_part().icon("list").noundo(),
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", DESCRIBE_CONTRACT, describe)).any_part()
.icon("list")
.noundo()
.doc_only()
@@ -213,12 +213,7 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
}
fn sketch_inspect(s: &mut Session, p: &Value) -> Result<Value> {
let id = match p.get("sketch") {
Some(Value::Number(n)) => s.doc.find_feature(&n.to_string()).map(|f| f.id),
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
_ => s.active_sketch,
}
.ok_or_else(|| bad("sketch.inspect", "no such sketch (and none is active)"))?;
let id = super::sketch_store::target(s, p, "sketch.inspect")?;
sketch_json(s, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into()))
}
+1
View File
@@ -189,6 +189,7 @@ fn face_ref(doc: &Document, name: &str) -> Value {
.filter(|(k, _)| k.strip_prefix('s').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())))
.map_or(curve, |(_, c)| c);
let found = f.kind.input_sketches().into_iter().find_map(|id| {
// design-only: a joint names a curve of one of the design's sketches.
let sk = doc.sketch(id).ok()?;
let c = sk.curves.iter().find(|c| c.id == curve)?;
let curve_type = serde_json::to_value(&c.kind).ok().and_then(|k| k.get("type").cloned()).unwrap_or(Value::Null);
+1 -1
View File
@@ -146,7 +146,7 @@ fn sketch_of<'a>(
if let Some(id) = sketch {
return st.sketch(id);
}
if let Some(ss) = s.active_sketch.and_then(|id| st.sketch(id)).filter(|ss| has(&ss.sketch)) {
if let Some(ss) = super::sketch_store::active(s).and_then(|id| st.sketch(id)).filter(|ss| has(&ss.sketch)) {
return Some(ss);
}
st.sketches.iter().rev().map(|ss| &**ss).find(|ss| has(&ss.sketch))
+48 -26
View File
@@ -2,10 +2,12 @@
//! `document.metadata.set` and `document.metadata.remove` change it, each in one undo step. The
//! keys SolidCraft reads, and the checks a change gets, are `solidcraft_doc::metadata`'s; keys
//! other tools wrote are kept as they are. The UI's Design Properties dialog, scripts and MCP
//! agents all go through these commands.
//! agents all go through these commands. A drawing's properties are read and changed by the
//! same rules ([`reading`], [`setting`], [`removing`]), through `drawing.properties`.
use serde_json::{Map, Value, json};
use solidcraft_doc::metadata::{MAX_KEYS, WELL_KNOWN};
use solidcraft_doc::Metadata;
use solidcraft_doc::metadata::{BOM_TYPE, MAX_KEYS, WELL_KNOWN};
use super::CommandSpec;
use crate::params::{bad, str_};
@@ -27,14 +29,16 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("key, or keys: [keys] (any key, also one another tool wrote) → metadata, removed: [keys that were there]"),
];
fn read(s: &mut Session, _: &Value) -> Result<Value> {
let m = &s.doc.metadata;
/// What reading `m` answers: the object as saved, and SolidCraft's properties, each its text or
/// null.
pub(super) fn reading(m: &Metadata) -> Value {
let properties: Map<String, Value> = WELL_KNOWN.iter().map(|k| (k.to_string(), m.text(k).map_or(Value::Null, |t| json!(t)))).collect();
Ok(json!({"metadata": m, "properties": properties}))
json!({"metadata": m, "properties": properties})
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "document.metadata.set";
/// `m` with a call's `key` and `value`, or its `values`, set (all or nothing), and the keys that
/// changed.
pub(super) fn setting(cmd: &str, m: &Metadata, p: &Value) -> Result<(Metadata, Vec<String>)> {
let entries: Vec<(&str, &Value)> = match (p.get("values"), p.get("key")) {
(Some(Value::Object(values)), None) if values.len() <= MAX_KEYS => values.iter().map(|(k, v)| (k.as_str(), v)).collect(),
(Some(Value::Object(_)), None) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
@@ -45,13 +49,47 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
_ => return Err(bad(cmd, "give `key` and `value`, or `values`: {key: value, …}")),
};
let mut next = s.doc.metadata.clone();
let mut next = m.clone();
let mut changed = Vec::new();
for (key, value) in entries {
if key == BOM_TYPE {
return Err(bad(cmd, "`bom_type` is a component's part property: set it with component.metadata.set"));
}
if next.set(key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
changed.push(key.to_string());
}
}
Ok((next, changed))
}
/// `m` with a call's `key`, or its `keys`, removed, and the keys that were there.
pub(super) fn removing(cmd: &str, m: &Metadata, p: &Value) -> Result<(Metadata, Vec<String>)> {
let keys: Vec<&str> = match (p.get("keys"), p.get("key")) {
(Some(Value::Array(keys)), None) if keys.len() <= MAX_KEYS => {
keys.iter().map(Value::as_str).collect::<Option<_>>().ok_or_else(|| bad(cmd, "`keys` must be a list of keys"))?
}
(None, Some(_)) => vec![str_(p, "key").ok_or_else(|| bad(cmd, "`key` must be text"))?],
_ => return Err(bad(cmd, format!("give `key`, or `keys`: a list of at most {MAX_KEYS} keys"))),
};
let mut removed: Vec<String> = Vec::new();
for k in keys {
if m.get(k).is_some() && !removed.iter().any(|r| r == k) {
removed.push(k.to_string());
}
}
let mut next = m.clone();
for k in &removed {
next.remove(k);
}
Ok((next, removed))
}
fn read(s: &mut Session, _: &Value) -> Result<Value> {
Ok(reading(&s.doc.metadata))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let (next, changed) = setting("document.metadata.set", &s.doc.metadata, p)?;
// Only a real change touches the design (an undo step, unsaved changes).
if next != s.doc.metadata {
s.doc_mut().metadata = next;
@@ -60,25 +98,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "document.metadata.remove";
let keys: Vec<&str> = match (p.get("keys"), p.get("key")) {
(Some(Value::Array(keys)), None) if keys.len() <= MAX_KEYS => {
keys.iter().map(Value::as_str).collect::<Option<_>>().ok_or_else(|| bad(cmd, "`keys` must be a list of keys"))?
}
(None, Some(_)) => vec![str_(p, "key").ok_or_else(|| bad(cmd, "`key` must be text"))?],
_ => return Err(bad(cmd, format!("give `key`, or `keys`: a list of at most {MAX_KEYS} keys"))),
};
let mut removed: Vec<&str> = Vec::new();
for k in keys {
if s.doc.metadata.get(k).is_some() && !removed.contains(&k) {
removed.push(k);
}
}
let (next, removed) = removing("document.metadata.remove", &s.doc.metadata, p)?;
if !removed.is_empty() {
let m = &mut s.doc_mut().metadata;
for k in &removed {
m.remove(k);
}
s.doc_mut().metadata = next;
}
Ok(json!({"metadata": s.doc.metadata, "removed": removed}))
}
+58 -4
View File
@@ -31,6 +31,8 @@ mod component_clone_assembly;
mod construct;
mod delete;
mod diagnostics;
mod drawing;
mod drawing_sketch;
mod edit;
mod face;
mod features;
@@ -82,6 +84,7 @@ mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod sketch_store;
mod surface;
mod surface_analysis;
mod surface_boundary;
@@ -102,6 +105,18 @@ pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::Live
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub type SchemaFn = fn() -> schemars::Schema;
/// The part of the design a command works on (#3890): the model (most commands), the open
/// drawing, or either (files, undo, selection, diagnostics, choosing a drawing).
/// [`CommandSpec::allowed`] checks it before the command's own `enabled`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
Model,
Drawing,
/// A shared sketch tool: model or the explicitly active sheet sketch.
Sketch,
Any,
}
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
@@ -130,6 +145,8 @@ pub struct CommandSpec {
pub contract: Option<&'static dyn crate::schema::Contract>,
/// The JSON Schema of a successful result; `None` until described.
pub output: Option<SchemaFn>,
/// The part of the design it works on: the model unless it says otherwise.
pub part: Part,
}
impl CommandSpec {
@@ -150,6 +167,7 @@ impl CommandSpec {
planned: None,
contract: None,
output: None,
part: Part::Model,
}
}
/// Not built yet; `issue` tracks building it.
@@ -202,14 +220,44 @@ impl CommandSpec {
self.undoable = false;
self
}
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
/// Works with a drawing open or with none.
pub const fn any_part(mut self) -> Self {
self.part = Part::Any;
self
}
/// Works on the open drawing.
pub const fn in_drawing(mut self) -> Self {
self.part = Part::Drawing;
self
}
/// Uses the shared sketch tool on its active model or explicit sheet receiver.
pub const fn in_sketch_part(mut self) -> Self {
self.part = Part::Sketch;
self
}
/// Whether the command can run in `s` now: on the part open, and enabled. The one check
/// running a command, running it inside another and listing it make.
pub fn allowed(&self, s: &Session) -> std::result::Result<(), String> {
if s.active_sheet_sketch.is_some() && matches!(self.id, "drawing.create" | "drawing.open" | "drawing.close" | "drawing.delete") {
return Err("finish or cancel the sheet sketch before switching drawings".into());
}
match (self.part, s.open_drawing.is_some()) {
(Part::Model, true) => Err("a drawing is open: close it to change the model (drawing.close)".into()),
(Part::Drawing, false) => Err("open a drawing first (drawing.open, or drawing.create)".into()),
(Part::Sketch, true) if !s.active_sheet_sketch.as_ref().is_some_and(|entry| Some(entry.drawing) == s.open_drawing) => {
Err("a drawing is open: enter a sheet sketch first (drawing.sketch.create or drawing.sketch.edit)".into())
}
_ => (self.enabled)(s),
}
}
/// Run inside another command: when it is allowed, its `run` (the caller's command owns the
/// undo step and the evaluation).
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
self.allowed(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
(self.run)(s, p)
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let e = self.allowed(s);
CommandInfo {
id: self.id,
label: self.label,
@@ -245,7 +293,11 @@ pub fn always(_: &Session) -> std::result::Result<(), String> {
}
pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
let active = match s.open_drawing {
Some(drawing) => s.active_sheet_sketch.as_ref().is_some_and(|entry| entry.drawing == drawing),
None => s.active_sketch.is_some(),
};
if active { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
}
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
@@ -303,6 +355,8 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(drawing::COMMANDS.iter());
v.extend(drawing_sketch::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v
+219 -69
View File
@@ -15,6 +15,7 @@ mod dimensioned;
mod drag;
#[path = "sketch_face.rs"]
pub(super) mod face;
use super::sketch_store;
use dimensioned::draw as draw_dimensioned;
use drag::drag_entity;
@@ -31,23 +32,30 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("sketch")
.params("plane: XY|XZ|YZ | construction plane name or id | {origin?: [x,y,z] numbers in mm, default [0,0,0] (not an expression), x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, flip?: bool | \"x\" | \"y\" (not on a face: sketch the plane from its other side, so the normal and the default extrude direction are reversed; a half turn about the plane's own x or y axis, true picks the one keeping the sketch's y axis upward; offset still moves along the unflipped normal), project_edges?: bool, name?"),
CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).in_sketch_part().at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(),
CommandSpec::new("sketch.cancel", "Cancel Sketch", crate::checked!("sketch.cancel", crate::schema::drawing_sketch::EMPTY, super::drawing_sketch::cancel))
.in_drawing().doc_only().icon("close").enabled(in_sketch)
.contract(&crate::schema::drawing_sketch::EMPTY).output(crate::schema::output_schema_for::<crate::schema::drawing_sketch::Cancelled>)
.params("Restore the active sheet sketch to its entry geometry (or remove a new sketch), close editing and record the change in drawing undo. Does not change other drawing data or the source model."),
CommandSpec::new("sketch.line", "Line", draw_line)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("line")
.key("L")
.enabled(in_sketch)
.params(concat!("points: [[x,y] | \"l1.end\" | {at, ref}…] (x and y each a number or an expression over the design's parameters, \"W / 2\": such a point is fixed where it comes to and follows the parameters; likewise wherever a point argument also takes a point id), closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.draw.dimensioned", "Draw Dimensioned Sketch Geometry", draw_dimensioned).icon("dimension")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("command: sketch creation command, params: object, dimensions: object of role to numeric or expression value; creates geometry and all driving dimensions atomically in one undo step", created_doc!())),
CommandSpec::new("sketch.rectangle.two_point", "2-Point Rectangle", rect_two_point)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("rect")
.key("R")
.enabled(in_sketch)
.params(concat!("p0, p1: opposite corners [x,y]; construction?. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.rectangle.three_point", "3-Point Rectangle", rect_three_point)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("rect3")
.enabled(in_sketch)
@@ -55,14 +63,16 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.rectangle.rounded", "Rounded Rectangle", rect_rounded)
.at("SKETCH", "CREATE")
.icon("rect_rounded")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("p0, p1: opposite corners [x,y]; radius: expr (the corner radius, less than half the shorter side); construction?. A 2-point rectangle whose four corners are filleted alike: one radius dimension (`param`), the other arcs equal to it. Returns sides {bottom, right, top, left} (line ids) and arcs (the corner arcs: bottom-left, bottom-right, top-right, top-left)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("rect_center")
.enabled(in_sketch)
.params(concat!("center, corner. Returns sides {bottom, right, top, left} (line ids) and corners {bottom_left, bottom_right, top_right, top_left} (point ids)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.circle.center", "Center Diameter Circle", circle_center)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("circle")
.key("C")
@@ -73,132 +83,152 @@ pub static COMMANDS: &[CommandSpec] = &[
created_doc!()
)),
CommandSpec::new("sketch.circle.two_point", "2-Point Circle", circle_two)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("circle2")
.enabled(in_sketch)
.params(concat!("p0, p1: diameter ends", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.circle.three_point", "3-Point Circle", circle_three)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("circle3")
.enabled(in_sketch)
.params(concat!("p0, p1, p2", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through: [x,y] or point ref (a referenced through point stays on the arc)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.arc.three_point", "3-Point Arc", arc_three).in_sketch_part().at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params(concat!("start, end, through: [x,y] or point ref (a referenced through point stays on the arc)", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.arc.center_point", "Center Point Arc", arc_center)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("arc_center")
.enabled(in_sketch)
.params(concat!("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.inscribed", "Inscribed Polygon", polygon_inscribed)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("polygon")
.enabled(in_sketch)
.params(concat!("center, radius (to vertices), sides, angle?: deg", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.circumscribed", "Circumscribed Polygon", polygon_circumscribed)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("polygon_circumscribed")
.enabled(in_sketch)
.params(concat!("center, radius (to edge midpoints), sides, angle?: deg", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.polygon.edge", "Edge Polygon", polygon_edge)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("polygon_edge")
.enabled(in_sketch)
.params(concat!("p0, p1: one edge, sides", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.center_to_center", "Center to Center Slot", slot_c2c)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("slot")
.enabled(in_sketch)
.params(concat!("p0, p1: arc centres, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.slot.overall", "Overall Slot", slot_overall)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("slot_overall")
.enabled(in_sketch)
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them), id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
CommandSpec::new("sketch.point", "Point", draw_point).in_sketch_part().at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them), id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).in_sketch_part().at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled. On sheet sketches, driven: true creates value-less reference dimensions; driving dimensions accept a paper-mm/degree expression (including read-only source parameter references) and persist it on the sheet without adding source-model parameters. A sheet rejects name = expression parameter definitions, scale_sketch and if_redundant fallback",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_hv")
.enabled(in_sketch)
.params("line: id | points: [a, b] (point ids or {midpoint: line_id}; accepted midpoints reuse or create a point with a Midpoint constraint); mode?: horizontal|vertical"),
CommandSpec::new("sketch.constraint.coincident", "Coincident", c_coincident)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_coincident")
.enabled(in_sketch)
.params("a, b: points, a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints)"),
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_tangent")
.enabled(in_sketch)
.params("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_equal")
.enabled(in_sketch)
.params("a, b: lines or circles/arcs"),
CommandSpec::new("sketch.constraint.parallel", "Parallel", c_parallel)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_parallel")
.enabled(in_sketch)
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_perpendicular")
.enabled(in_sketch)
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.fix", "Fix/UnFix", c_fix)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_fix")
.enabled(in_sketch)
.params("entity: point or curve | entities: [points and curves]; fixed?: bool (default: fix them all unless all are fixed, then unfix)"),
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", c_midpoint)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_midpoint")
.enabled(in_sketch)
.params("point, line | a, b in pick order: a point and a line (either way round), or two lines whose midpoints meet (the first stays, the second moves)"),
CommandSpec::new("sketch.constraint.concentric", "Concentric", c_concentric)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_concentric")
.enabled(in_sketch)
.params("a, b: circles/arcs"),
CommandSpec::new("sketch.constraint.collinear", "Collinear", c_collinear)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_collinear")
.enabled(in_sketch)
.params("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)"),
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
.in_sketch_part()
.at("SKETCH", "CONSTRAINTS")
.icon("c_symmetry")
.enabled(in_sketch)
.params("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created); or point (or a point id as line, \"origin\" too): symmetric about that point, each pair joined by a construction line it is the midpoint of"),
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
.in_sketch_part()
.at("SKETCH", "CREATE")
.icon("construction")
.key("X")
.enabled(in_sketch)
.params("curves: [ids], value?: bool (default: construction unless all of them already are)"),
CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete).icon("delete")
CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete).in_sketch_part().icon("delete")
.enabled(in_sketch)
.params("entities: [curve, point or constraint ids]"),
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).icon("sketch_move").enabled(in_sketch).live(drag_live).params(
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).in_sketch_part().icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).in_sketch_part().icon("sketch_move").enabled(in_sketch).live(drag_live).params(
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: points and the material point grabbed on a line move toward `to` within the design constraints. When exact placement is impossible, bounded feasible steps minimize cursor distance. Splines slide by to - from; a circle or arc grabbed on its edge takes the radius to `to`. `with?`: [point or curve ids] moves the rest of a selection with the entity as one translation (circles keep their radii). Long moves retain the solution branch; arcs cannot sweep through their starts. Returns `reached`: progress projected onto the requested move (0..1), `at`: the grabbed position for the next frame, and `retry`: budget-limited progress can continue in another frame. `live: true` shares a work budget across held-solve decomposition and cursor correction; ordinary recovery and the final model evaluation retain their existing limits, not a wall-clock deadline. Later features stay stale until `sketch.drag_end`; `dof` stays from before the drag",
),
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).icon("sketch_move").enabled(in_sketch).doc_only().params(
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
),
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text).icon("edit")
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text).in_sketch_part().icon("edit")
.enabled(in_sketch)
.params("dimension: constraint id or parameter name; at: [x,y] text centre (sketch coordinates) | reset: true (default place)"),
CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).icon("sketch").params(
CommandSpec::new("sketch.options", "Sketch Palette Options", sketch_options).in_sketch_part().icon("sketch").params(
"sketch?: id|name (default active); any of show_profile, show_points, show_dimensions, show_constraints, show_projected, show_construction, grid, snap, slice, sketch_3d: bool. Returns them all",
),
CommandSpec::new("sketch.chain", "Chain", chain_cmd).icon("chain")
CommandSpec::new("sketch.chain", "Chain", chain_cmd).in_sketch_part().icon("chain")
.enabled(in_sketch)
.noundo()
.params("curve: id: the curves joined to it end to end (what a double-click selects)"),
CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd).icon("compute")
CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd).in_sketch_part().icon("compute")
.enabled(in_sketch)
.noundo()
.params("reports the solver status and degrees of freedom"),
@@ -207,23 +237,6 @@ pub static COMMANDS: &[CommandSpec] = &[
// ---------------------------------------------------------------------------------------------
// Helpers
/// Resolve the sketch a command acts on: `sketch` param (id or name) or the active sketch.
pub(super) fn target_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
if let Some(v) = p.get("sketch") {
let key = match v {
Value::Number(n) => n.to_string(),
Value::String(x) => x.clone(),
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad(cmd, format!("`{key}` is not a sketch")));
}
return Ok(f.id);
}
s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))
}
/// Edit a sketch: clone it, apply `f`, solve with the document's dimension values and store
/// it. With `strict`, an edit that makes the constraints unsolvable is rejected.
pub(super) fn edit<T>(
@@ -233,7 +246,10 @@ pub(super) fn edit<T>(
strict: bool,
f: impl FnOnce(&mut Sketch, &mut solidcraft_doc::Document) -> Result<T>,
) -> Result<(T, Value)> {
let id = target_sketch(s, p, cmd)?;
if s.open_drawing.is_some() {
return sketch_store::edit_sheet(s, p, cmd, f);
}
let id = sketch_store::target(s, p, cmd)?;
let (out, rep) = edit_solved(s, id, strict, Analysis::Freedom, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?;
Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null)))
}
@@ -277,7 +293,7 @@ pub(super) fn edit_solved<T>(
f: impl FnOnce(&mut Sketch, &mut solidcraft_doc::Document) -> Result<(T, Left)>,
) -> Result<(T, Option<SolveReport>)> {
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
let mut sk = sketch_store::read(&doc, id)?.clone();
super::sketch_project::refresh(s, &doc, id, &mut sk);
// The stored sketch is solved for its stored values.
let stored = sk.driving_values();
@@ -313,8 +329,8 @@ pub(super) fn edit_solved<T>(
Some(settle(strict, rep, &mut sk, unsolved)?)
}
};
sk.validate_periodic_splines()?;
*doc.sketch_mut(id)? = sk;
sketch_store::checked(&sk)?;
sketch_store::write(&mut doc, id, sk)?;
*s.doc_mut() = doc;
Ok((out, rep))
}
@@ -443,10 +459,14 @@ impl PArg {
}
pub(super) fn parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, v: &Value, cmd: &str) -> Result<PArg> {
parg_in(sk, doc, v, cmd, false)
}
fn parg_in(sk: &mut Sketch, doc: &solidcraft_doc::Document, v: &Value, cmd: &str, paper: bool) -> Result<PArg> {
if let Some(p) = vec2(v) {
return Ok(PArg::At(p));
}
if let Some((at, exprs)) = expr_point(doc, v, cmd)? {
if let Some((at, exprs)) = expr_point_in(doc, v, cmd, paper)? {
return Ok(match exprs {
Some(exprs) => PArg::Ref(add_placed_point(sk, at, exprs, None)?, at),
None => PArg::At(at),
@@ -468,20 +488,24 @@ pub(super) fn parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, v: &Value, c
}
pub(super) fn req_parg(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value, k: &str, cmd: &str) -> Result<PArg> {
parg(sk, doc, p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?, cmd)
req_parg_in(sk, doc, p, k, cmd, false)
}
fn req_parg_in(sk: &mut Sketch, doc: &solidcraft_doc::Document, p: &Value, k: &str, cmd: &str, paper: bool) -> Result<PArg> {
parg_in(sk, doc, p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?, cmd, paper)
}
const POINT_MUST: &str = "a point's coordinates must be numbers or expressions over the design's parameters";
/// A point given as `[x, y]` with a coordinate typed as text: where it is now and, when a
/// coordinate names a parameter, the expressions that place it (none: a plain point).
pub(super) fn expr_point(doc: &solidcraft_doc::Document, v: &Value, cmd: &str) -> Result<Option<(Vec2, Option<PointExprs>)>> {
fn expr_point_in(doc: &solidcraft_doc::Document, v: &Value, cmd: &str, paper: bool) -> Result<Option<(Vec2, Option<PointExprs>)>> {
let Some([x, y]) = v.as_array().map(Vec::as_slice) else { return Ok(None) };
if !x.is_string() && !y.is_string() {
return Ok(None);
}
let (px, ex) = super::sketch_create::text_number(doc, x, cmd, "point x", POINT_MUST, false)?;
let (py, ey) = super::sketch_create::text_number(doc, y, cmd, "point y", POINT_MUST, false)?;
let (px, ex) = super::sketch_create::text_number_in(doc, x, cmd, "point x", POINT_MUST, false, paper)?;
let (py, ey) = super::sketch_create::text_number_in(doc, y, cmd, "point y", POINT_MUST, false, paper)?;
let exprs = (ex.is_some() || ey.is_some()).then_some(PointExprs { x: ex, y: ey });
Ok(Some((Vec2::new(px, py), exprs)))
}
@@ -791,6 +815,9 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result<Value> {
}
fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
if s.open_drawing.is_some() {
return super::drawing_sketch::finish(s, _p);
}
let id = s.active_sketch.take();
s.revision += 1;
let profiles = id.and_then(|i| s.model.state().sketch(i).map(|ss| ss.profiles.len())).unwrap_or(0);
@@ -802,6 +829,8 @@ fn finish_sketch(s: &mut Session, _p: &Value) -> Result<Value> {
fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.line";
let paper = s.open_drawing.is_some();
// The shared preprocessor refuses model snaps before a sheet tool reads them.
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let list = p.get("points").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`points` must be a list"))?;
if list.len() < 2 || list.len() > 10_000 {
@@ -811,7 +840,7 @@ fn draw_line(s: &mut Session, p: &Value) -> Result<Value> {
let infer = bool_(p, "infer").unwrap_or(false);
let ids = string_list(p, "ids");
let ((curves, cons), info) = draw(s, p, cmd, |sk, doc| {
let args: Vec<PArg> = list.iter().map(|v| parg(sk, doc, v, cmd)).collect::<Result<_>>()?;
let args: Vec<PArg> = list.iter().map(|v| parg_in(sk, doc, v, cmd, paper)).collect::<Result<_>>()?;
let n = args.len();
let segs = if closed { n } else { n - 1 };
let mut pts: Vec<Option<usize>> = args.iter().map(PArg::idx).collect();
@@ -1066,6 +1095,9 @@ fn rect_rounded(s: &mut Session, p: &Value) -> Result<Value> {
}
o.insert("arcs".into(), arcs);
o.insert("param".into(), rounded.get("param").cloned().unwrap_or(Value::Null));
if let Some(expression) = rounded.get("expression").filter(|_| s.open_drawing.is_some()) {
o.insert("expression".into(), expression.clone());
}
if let Some(info) = rounded.get("sketch") {
o.insert("sketch".into(), info.clone());
}
@@ -1140,18 +1172,19 @@ pub(super) fn radius_arg(p: &Value, cmd: &str) -> Result<f64> {
fn circle_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.circle.center";
let paper = s.open_drawing.is_some();
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let r = radius_arg(p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let (out, info) = draw(s, p, cmd, |sk, doc| {
let c = req_parg(sk, doc, p, "center", cmd)?;
let c = req_parg_in(sk, doc, p, "center", cmd, paper)?;
let ci = sk.add_circle(c.pos(), r, c.idx(), id.as_deref())?;
mark_construction(sk, &[ci], p);
// A point the circle was drawn through stays on it (#3099): with a placed centre, that
// sets the radius.
let mut cons = Vec::new();
if let Some(v) = p.get("through")
&& let PArg::Ref(q, _) = parg(sk, doc, v, cmd)?
&& let PArg::Ref(q, _) = parg_in(sk, doc, v, cmd, paper)?
{
cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: q, c: ci })?);
}
@@ -1201,11 +1234,12 @@ fn circle_three(s: &mut Session, p: &Value) -> Result<Value> {
fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.three_point";
let paper = s.open_drawing.is_some();
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let (out, info) = draw(s, p, cmd, |sk, doc| {
let (a, b) = (req_parg(sk, doc, p, "start", cmd)?, req_parg(sk, doc, p, "end", cmd)?);
let t = req_parg(sk, doc, p, "through", cmd)?;
let (a, b) = (req_parg_in(sk, doc, p, "start", cmd, paper)?, req_parg_in(sk, doc, p, "end", cmd, paper)?);
let t = req_parg_in(sk, doc, p, "through", cmd, paper)?;
let (o, _) = circumcircle(a.pos(), t.pos(), b.pos()).ok_or_else(|| bad(cmd, "the points are collinear"))?;
// Counter-clockwise from start to end must pass the through point; otherwise swap.
let ang = |q: Vec2| (q - o).angle();
@@ -1227,12 +1261,13 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.arc.center_point";
let paper = s.open_drawing.is_some();
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let id = str_(p, "id").map(str::to_string);
let sweep = num(p, "sweep");
let (out, info) = draw(s, p, cmd, |sk, doc| {
let c = req_parg(sk, doc, p, "center", cmd)?;
let a = req_parg(sk, doc, p, "start", cmd)?;
let c = req_parg_in(sk, doc, p, "center", cmd, paper)?;
let a = req_parg_in(sk, doc, p, "start", cmd, paper)?;
if let Some(sw) = sweep {
// Start point and signed sweep (degrees); clockwise arcs keep `<id>.start` at the start.
if !(sw.abs() > 1e-9 && sw.abs() < 360.0) {
@@ -1259,7 +1294,7 @@ fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
};
return Ok((ids_of(sk, &[ci]), Vec::new()));
}
let b = req_parg(sk, doc, p, "end", cmd)?;
let b = req_parg_in(sk, doc, p, "end", cmd, paper)?;
let ci = sk.add_arc(c.pos(), a.pos(), b.pos(), [c.idx(), a.idx(), b.idx()], id.as_deref())?;
Ok((ids_of(sk, &[ci]), Vec::new()))
})?;
@@ -1385,9 +1420,10 @@ fn slot_overall(s: &mut Session, p: &Value) -> Result<Value> {
fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.point";
let paper = s.open_drawing.is_some();
let id = str_(p, "id").map(str::to_string);
let ((pid, cons), info) = edit(s, p, cmd, false, |sk, doc| {
let i = match expr_point(doc, p.get("point").unwrap_or(&Value::Null), cmd)? {
let i = match expr_point_in(doc, p.get("point").unwrap_or(&Value::Null), cmd, paper)? {
Some((at, Some(exprs))) => add_placed_point(sk, at, exprs, id.as_deref())?,
Some((at, None)) => sk.add_point(at, id.as_deref())?,
None => sk.add_point(req_vec2(cmd, p, "point")?, id.as_deref())?,
@@ -1593,7 +1629,7 @@ pub(super) fn redundancy(
/// The constraints that already determine constraint `cid` of the target sketch, as
/// "id (Name)".
fn determined_by(s: &Session, p: &Value, cmd: &str, cid: &str) -> Vec<String> {
let Some(sk) = target_sketch(s, p, cmd).ok().and_then(|id| s.doc.sketch(id).ok()) else { return Vec::new() };
let Some(sk) = sketch_store::target(s, p, cmd).ok().and_then(|id| sketch_store::read(&s.doc, id).ok()) else { return Vec::new() };
let Some(ci) = sk.constraints.iter().position(|c| c.id == cid) else { return Vec::new() };
solidcraft_sketch::determined_by(sk, ci)
.into_iter()
@@ -1727,9 +1763,9 @@ fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.constraint.coincident";
// Already tied (e.g. connected when it was drawn there): nothing to add.
let sk = s.doc.sketch(target_sketch(s, p, cmd)?)?;
let sk = sketch_store::read_for_command(s, p, cmd)?;
let pt = |k: &str| p.get(k).and_then(Value::as_str).and_then(|r| sk.resolve_point(r));
let line_points = coincident_line_points(sk, p, cmd)?;
let line_points = coincident_line_points(&sk, p, cmd)?;
if let Some((a, b)) = line_points.or_else(|| pt("a").zip(pt("b")))
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
{
@@ -2092,9 +2128,27 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
("offset", _) => "distance".to_string(),
_ => ty,
};
let value = expr(p, "value");
// The model's optional-value compatibility stays untouched. A sheet value,
// when explicitly supplied, must parse: an overlong/invalid expression must
// not silently become an omitted value and drive the current measurement.
let value = if s.open_drawing.is_some() && p.get("value").is_some() { Some(crate::params::req_expr(cmd, p, "value")?) } else { expr(p, "value") };
let text_at = p.get("text_at").and_then(vec2);
let driven = bool_(p, "driven").unwrap_or(false);
// Sheet dimensions have their own authored paper expression and never define
// a parameter on the source model. Reference dimensions remain value-less.
let on_sheet = s.open_drawing.is_some();
if on_sheet && driven && value.is_some() {
return Err(bad(cmd, "a sheet sketch reference dimension cannot define or take a value"));
}
if on_sheet && bool_(p, "scale_sketch").unwrap_or(false) {
return Err(bad(cmd, "scaling a sheet sketch through a dimension is not supported"));
}
if on_sheet && value.as_ref().is_some_and(|v| solidcraft_doc::expr::definition(v.as_str()).is_some()) {
return Err(bad(cmd, "a sheet dimension cannot define source parameters; use a paper expression or a read-only source parameter"));
}
if on_sheet && !driven && str_(p, "if_redundant").is_some() {
return Err(bad(cmd, "sheet sketch driving dimensions do not support automatic reference fallback"));
}
// `name = expression` defines the user parameter `name` and dimensions with it (#2916).
let (defines, value) = match value.as_ref().and_then(|v| solidcraft_doc::expr::definition(v.as_str())) {
Some((name, rhs)) => {
@@ -2274,6 +2328,26 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
let kind = if is_angle { Kind::Angle } else { Kind::Length };
if on_sheet {
let authored = value.as_ref().map(|v| v.as_str().to_string()).unwrap_or(default_expr);
if authored.len() > 4096 || authored.trim().is_empty() || authored.chars().any(char::is_control) {
return Err(bad(cmd, "sheet dimension expression must be nonempty, at most 4096 bytes and contain no controls"));
}
// eval uses paper-mm / degree defaults, not the model's display units.
// Source names are read-only; the same authored expression is persisted
// on the sheet dimension and re-resolved whenever the drawing is read.
let wanted = doc.eval(&authored, kind).map_err(|e| bad(cmd, format!("value: {e}")))?;
if let Some((alternate, alternate_value)) = alt
&& (wanted - alternate_value).abs() < (wanted - cur).abs()
{
k = alternate;
}
k.set_value(wanted);
let name = k.name();
let id = sk.add_constraint(k, Some(authored.clone()))?;
place_text(sk, &id, text_at);
return Ok((authored, name, measured(is_angle, cur)));
}
if let Some((name, rhs)) = &defines {
// The new user parameter reads a bare number as the dimension would (the design's
// unit for a length, degrees for an angle), and carries that unit.
@@ -2321,10 +2395,25 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
Ok((pname, name, measured(is_angle, cur)))
})?;
// Report what the dimension measures once the sketch is solved, not the geometry before.
let current = solved_measure(s, p, cmd).unwrap_or(current);
// The model-only lookup cannot inspect the drawing-owned sketch.
let current = if on_sheet {
let entry = sketch_store::sheet_target(s, p, cmd)?;
let owner = s.drawing(cmd)?;
let sk = &owner.sheet_sketch(entry.sketch).ok_or_else(|| bad(cmd, "the edited sheet sketch no longer exists"))?.sketch;
let latest = sk.constraints.last().ok_or_else(|| bad(cmd, "the sheet dimension was not stored"))?;
let value = solidcraft_sketch::measure_dimension(sk, &latest.kind).ok_or_else(|| bad(cmd, "could not measure the sheet dimension"))?;
measured(latest.kind.is_angle(), value)
} else {
solved_measure(s, p, cmd).unwrap_or(current)
};
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}));
}
if on_sheet {
return Ok(
json!({"param": Value::Null, "driven": false, "expression": param, "type": kind_name, "measured": current.0, "unit": current.1, "sketch": info}),
);
}
// A dimension the sketch already determines cannot drive it: it is refused, or with
// `if_redundant: "driven"` placed as a driven (reference) dimension instead.
let by = redundancy(s, p, cmd, before, &info, residual, &new_id)
@@ -2366,7 +2455,7 @@ fn measured(is_angle: bool, v: f64) -> (f64, &'static str) {
/// What the dimension just added (the sketch's last constraint) measures on the solved sketch.
fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static str)> {
let sk = s.doc.sketch(target_sketch(s, p, cmd).ok()?).ok()?;
let sk = sketch_store::read(&s.doc, sketch_store::target(s, p, cmd).ok()?).ok()?;
let k = &sk.constraints.last()?.kind;
Some(measured(k.is_angle(), solidcraft_sketch::measure_dimension(sk, k)?))
}
@@ -2550,6 +2639,9 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad(cmd, "`entities` must list sketch entity ids"));
}
// Sheet edits may remove sheet-local entities, never model parameter definitions.
// The model path retains its existing parameter-pruning behavior.
let model_edit = s.open_drawing.is_none();
let (n, info) = edit(s, p, cmd, false, |sk, doc| {
let mut curves = Vec::new();
let mut points = Vec::new();
@@ -2605,18 +2697,32 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
sk.remove_curves(&curves);
let pidx: Vec<usize> = pids.iter().filter_map(|id| sk.point_index(id)).collect();
sk.remove_points(&pidx);
doc.prune_model_params();
if model_edit {
doc.prune_model_params();
}
Ok(ids.len())
})?;
// Dimension parameters may have been dropped; prune again with the stored sketch.
s.doc_mut().prune_model_params();
// Model sketch dimensions may have retired parameters; a sheet sketch never can.
if model_edit {
s.doc_mut().prune_model_params();
}
Ok(json!({"deleted": n, "sketch": info}))
}
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.move_point";
let to = req_vec2(cmd, p, "to")?;
let id = target_sketch(s, p, cmd)?;
if s.open_drawing.is_some() {
// Reuse the existing point drag on solved paper coordinates. Only the
// shared sheet receiver publishes one drawing-local undo step.
let (_, report) = edit(s, p, cmd, false, |sk, _| {
let q = point_ref(sk, p.get("point"), cmd, "point")?;
let _ = drag_entity(sk, Ent::Point(q), &[], to, to, false, None);
Ok(())
})?;
return Ok(json!({"sketch": report}));
}
let id = sketch_store::target(s, p, cmd)?;
let state = s.model.result(id).map(|r| r.state.clone());
let last = state.as_ref().and_then(|state| state.sketch(id));
let (_, rep) = edit_solved(s, id, false, Analysis::Freedom, |sk, _| {
@@ -2642,7 +2748,37 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
None | Some(Value::Null) => to,
Some(v) => vec2(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y]"))?,
};
let id = target_sketch(s, p, cmd)?;
if s.open_drawing.is_some() {
// The drawing editor publishes a complete gesture once. Live frames remain
// model-only until the common UI gesture host owns a sheet transaction.
if live || p.get("live").is_some_and(|v| !v.is_null() && v != &Value::Bool(false)) {
return Err(bad(cmd, "live sheet drag needs the Drawing pointer gesture host; use a completed non-live sketch.drag edit"));
}
let entry = sketch_store::sheet_target(s, p, cmd)?;
let (moved, report) = edit(s, p, cmd, false, |sk, _| {
let id = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "entity must be a sketch point or curve"))?;
let entity = ent(sk, id).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{id}`")))?;
let companions = match p.get("with") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(ids)) if ids.len() <= sk.points.len() + sk.curves.len() => ids
.iter()
.map(|v| {
let name = v.as_str().ok_or_else(|| bad(cmd, "with must list sketch point or curve ids"))?;
ent(sk, name).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{name}`")))
})
.collect::<Result<Vec<_>>>()?,
_ => return Err(bad(cmd, "with must be a bounded list of sketch point or curve ids")),
};
let (out, _) = drag_entity(sk, entity, &companions, from, to, false, None);
Ok(out)
})?;
let owner = s.drawing(cmd)?;
let sketch = &owner.sheet_sketch(entry.sketch).ok_or_else(|| bad(cmd, "the edited sheet sketch no longer exists"))?.sketch;
let at = moved.at(sketch, from);
let result = json!({"sketch": report, "reached": moved.reached, "at": [at.x, at.y], "retry": moved.retry});
return Ok((result, None));
}
let id = sketch_store::target(s, p, cmd)?;
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
let state = s.model.result(id).map(|r| r.state.clone());
let last = state.as_ref().and_then(|state| state.sketch(id));
@@ -2679,7 +2815,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
})?;
// Lines retain the grabbed material point through rotation. Other entities report the
// average displacement of their moving points, as before.
let sk = s.doc.sketch(id)?;
let sk = sketch_store::read(&s.doc, id)?;
let at = d.at(sk, from);
let rep = rep.or_else(|| last.map(|ss| SolveReport { status: solidcraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..ss.report.clone() }));
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y], "retry": d.retry});
@@ -2691,7 +2827,7 @@ fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<c
/// evaluate first.
fn drag_end(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.drag_end";
let id = target_sketch(s, p, cmd)?;
let id = sketch_store::target(s, p, cmd)?;
let start = s.doc.clone();
let mut constraint = Value::Null;
if let Some(c) = p.get("constraint").filter(|c| !c.is_null()) {
@@ -2754,8 +2890,11 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
}
let set = |k: &str| bool_(p, k);
if keys.iter().all(|k| set(k).is_none()) {
let id = target_sketch(s, p, cmd)?;
return Ok(json!({"options": options_json(&s.doc.sketch(id)?.view)}));
let sk = sketch_store::read_for_command(s, p, cmd)?;
return Ok(json!({"options": options_json(&sk.view)}));
}
if s.open_drawing.is_some() && (set("slice") == Some(true) || set("sketch_3d") == Some(true)) {
return Err(bad(cmd, "slice and 3D sketch options are model-only"));
}
let (v, info) = edit(s, p, cmd, false, |sk, _| {
let v = &mut sk.view;
@@ -2781,14 +2920,25 @@ fn sketch_options(s: &mut Session, p: &Value) -> Result<Value> {
fn chain_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.chain";
let id = target_sketch(s, p, cmd)?;
let sk = s.doc.sketch(id)?;
let c = curve_ref(sk, p.get("curve"), cmd, "curve")?;
Ok(json!({"curves": ids_of(sk, &sk.chain(c))}))
let sk = sketch_store::read_for_command(s, p, cmd)?;
let c = curve_ref(&sk, p.get("curve"), cmd, "curve")?;
Ok(json!({"curves": ids_of(&sk, &sk.chain(c))}))
}
fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = target_sketch(s, p, "sketch.solve")?;
let cmd = "sketch.solve";
if s.open_drawing.is_some() {
let entry = sketch_store::sheet_target(s, p, cmd)?;
let solved = s.drawing(cmd)?.solve_sheet_sketch(&s.doc, entry.sketch)?;
let profiles = solidcraft_sketch::find_drawn_profiles(&solved.sketch).len();
return Ok(json!({
"status": solved.report.status,
"dof": solved.report.dof,
"failing": solved.report.failing,
"profiles": profiles,
}));
}
let id = sketch_store::target(s, p, cmd)?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch has not been evaluated".into()))?;
Ok(json!({"status": ss.report.status, "dof": ss.report.dof, "failing": ss.report.failing, "profiles": ss.profiles.len()}))
+3 -2
View File
@@ -6,7 +6,8 @@ use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_sketch::WireSpace;
use super::sketch::{edit, target_sketch};
use super::sketch::edit;
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, req_vec3, vec3};
use crate::{Result, Session};
@@ -45,7 +46,7 @@ fn needs_3d(sk: &solidcraft_sketch::Sketch, cmd: &str) -> Result<()> {
/// World command inputs belong to the target sketch's owner, which may differ from the
/// active component when a script supplies an explicit sketch.
fn inverse_owner_frame(s: &Session, p: &Value, cmd: &str) -> Result<solidcraft_doc::Mat> {
let id = target_sketch(s, p, cmd)?;
let id = sketch_store::target(s, p, cmd)?;
let owner = s.doc.feature(id).map(|f| f.component).ok_or_else(|| bad(cmd, "target sketch is missing"))?;
if owner != 0 && owner != s.active_component {
if s.doc.occurrences.len() > 100_000 {
+5 -4
View File
@@ -11,7 +11,8 @@ use serde_json::{Value, json};
use solidcraft_geom::{Aabb2, Vec2};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
use super::sketch::{edit, target_sketch};
use super::sketch::edit;
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, string_list, vec2};
use crate::{Result, Sel, Session};
@@ -91,12 +92,12 @@ fn text_of(sk: &Sketch, ci: usize) -> Option<&str> {
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.copy";
let id = target_sketch(s, p, cmd)?;
let id = sketch_store::target(s, p, cmd)?;
let ids = chosen(s, p);
if ids.is_empty() {
return Err(bad(cmd, "select the sketch curves or points to copy"));
}
let sk = s.doc.sketch(id)?;
let sk = sketch_store::read(&s.doc, id)?;
let (mut cs, mut ps, mut texts): (Vec<usize>, Vec<usize>, Vec<String>) = (Vec::new(), Vec::new(), Vec::new());
for e in &ids {
if let Some(c) = sk.curve_index(e) {
@@ -244,7 +245,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "params must be an object"));
}
let Some(clip) = s.sketch_clipboard.clone() else { return Err(bad(cmd, "nothing copied (sketch.copy first)")) };
let target = target_sketch(s, p, cmd)?;
let target = sketch_store::target(s, p, cmd)?;
let positioned = p.get("at").is_some() || p.get("translate").is_some();
let t = if let Some(v) = p.get("at") {
vec2(v).ok_or_else(|| bad(cmd, "`at` must be [x, y]"))? - clip.center
+5 -9
View File
@@ -6,6 +6,7 @@ use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
use super::sketch::{add_c, constraint_result, dof_now, edit, listed, redundancy, residual_of};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list, vec2};
use crate::{EngineError, Result, Session};
@@ -396,8 +397,8 @@ fn toggle_driven(s: &mut Session, p: &Value) -> Result<Value> {
})?;
if !driven {
// Making it driving must not over-constrain: compare with the sketch without it.
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch"))?;
let mut sk = s.doc.sketch(id)?.clone();
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
let mut sk = sketch_store::read(&s.doc, id)?.clone();
if let Some(c) = sk.constraints.iter_mut().find(|c| c.id == cid) {
c.driven = true;
}
@@ -457,12 +458,7 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
}
fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
let id = match p.get("sketch") {
Some(Value::Number(n)) => n.as_u64(),
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
_ => s.active_sketch,
}
.ok_or_else(|| bad("sketch.glyphs", "no such sketch"))?;
let id = sketch_store::target(s, p, "sketch.glyphs")?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
let failing = &ss.report.failing;
@@ -496,7 +492,7 @@ fn snap(s: &mut Session, p: &Value) -> Result<Value> {
let at = req_vec2(cmd, p, "at")?;
let from = p.get("from").and_then(vec2);
let r = num(p, "radius").filter(|r| *r > 0.0).unwrap_or(1.0);
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
let sk = &ss.sketch;
+56 -41
View File
@@ -13,6 +13,7 @@ macro_rules! text_style_doc {
}
use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, str_, string_list};
use crate::{Result, Session};
@@ -29,65 +30,65 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line)
.at("SKETCH", "CREATE")
.icon("line_mid")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("mid: [x,y] or point ref, end: [x,y]; construction?", created_doc!())),
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").in_sketch_part().enabled(in_sketch).params(concat!(
"start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)",
created_doc!()
)),
CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan)
.at("SKETCH", "CREATE")
.icon("circle_tt")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan)
.at("SKETCH", "CREATE")
.icon("circle_ttt")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())),
CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point)
.at("SKETCH", "CREATE")
.icon("slot_center")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("center: slot centre, end: one arc centre, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three)
.at("SKETCH", "CREATE")
.icon("arc_slot")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("start, through, end: points on the centre arc, width", created_doc!())),
CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center)
.at("SKETCH", "CREATE")
.icon("arc_slot_center")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())),
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!(
CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").in_sketch_part().enabled(in_sketch).params(concat!(
"center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius",
created_doc!()
)),
CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit)
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("points: control points (2…500), cubic", created_doc!())),
CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("points: control points (2…500), degree 5", created_doc!())),
CommandSpec::new("sketch.conic", "Conic Curve", conic)
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())),
CommandSpec::new("sketch.blend_curve", "Blend Curve", blend)
.at("SKETCH", "MODIFY")
.icon("blend")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!(
"a, b: end points of two curves (\"l1.end\", \"a2.start\"), continuity?: g1|g2|g3|g4 (default g1: a tangent spline; g2…g4 match the reference curves' curvature, curvature rate and 4th derivative as well, as one Bézier of degree 5, 7 or 9)",
created_doc!()
@@ -95,10 +96,10 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.centerline", "Centerline", centerline)
.at("SKETCH", "CREATE")
.icon("centerline")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"),
CommandSpec::new("sketch.infinite", "Infinite Line", infinite_line).icon("infinite")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params("curves: [line ids], value?: bool (default toggles): infinite line type (#1359): construction geometry drawn across the whole view through the line's two points; off, a normal line"),
CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("sketch_text").enabled(in_sketch).params(concat!(
"text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg; at's x and y, height and angle each take a number or an expression over the design's parameters as a string (\"plate_width / 2\", \"label_size\"): the text keeps the expression, follows the parameters, and returns it in `exprs`; an expression naming no parameter is the number it comes to, a bare number in one is in the design's units, \
@@ -167,6 +168,20 @@ const AT_MUST: &str = "`at` must be the baseline start [x, y], each a number or
/// to. Returns the value (mm or radians) and the expression kept. Anything else is refused with
/// `must`.
pub(super) fn text_number(doc: &solidcraft_doc::Document, v: &Value, cmd: &str, key: &str, must: &str, angle: bool) -> Result<(f64, Option<String>)> {
text_number_in(doc, v, cmd, key, must, angle, false)
}
/// Shared numeric parsing with explicit paper defaults. The source parameter
/// evaluator still sees the original design and its own parameter units.
pub(super) fn text_number_in(
doc: &solidcraft_doc::Document,
v: &Value,
cmd: &str,
key: &str,
must: &str,
angle: bool,
paper: bool,
) -> Result<(f64, Option<String>)> {
use solidcraft_doc::expr::{self, Kind};
let kind = if angle { Kind::Angle } else { Kind::Length };
match v {
@@ -175,7 +190,7 @@ pub(super) fn text_number(doc: &solidcraft_doc::Document, v: &Value, cmd: &str,
Ok((if angle { x.to_radians() } else { x }, None))
}
Value::String(e) if !e.trim().is_empty() && e.len() <= 4096 => {
let e = doc.with_design_unit(e.trim(), kind);
let e = if paper { e.trim().to_string() } else { doc.with_design_unit(e.trim(), kind) };
match doc.eval(&e, kind) {
Ok(x) => {
let x = crate::params::finite(x).ok_or_else(|| bad(cmd, must))?;
@@ -262,20 +277,19 @@ fn text(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, AT_MUST));
}
let src = text_source(&s.doc, p, cmd, None)?;
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
// A text needs nothing outside its sketch (a path is one of its curves).
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
if geom.is_empty() {
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(&mut sk, &src);
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let exprs = text_exprs_json(&src);
let l = sk.add_link(LinkKind::Text, src, &geom)?;
let curves = ids_of(&sk, &sk.link_curves(&l));
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
let (l, curves, guide) = sketch_store::update(s, id, |_, _, sk| {
// A text needs nothing outside its sketch (a path is one of its curves).
let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?;
if geom.is_empty() {
return Err(bad(cmd, "the text makes no outline"));
}
let guide = guide(sk, &src);
let l = sk.add_link(LinkKind::Text, src, &geom)?;
let curves = ids_of(sk, &sk.link_curves(&l));
Ok((l, curves, guide))
})?;
let mut out = json!({"link": l, "curves": curves, "guide": guide});
if let Some(exprs) = exprs {
out["exprs"] = exprs;
@@ -350,23 +364,20 @@ const MAX_TEXT_EDITS: usize = 1000;
fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.edit_text";
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
// The numbers its expressions give now (a parameter may have changed since the sketch was
// last stored); one that cannot be evaluated is left as it was, to be replaced by this edit.
let _ = doc.apply_text_values(&doc.param_values().0, &mut sk);
let out = apply_text_edits(&mut sk, &doc, p)?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(out)
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
sketch_store::update(s, id, |_, doc, sk| {
// The numbers its expressions give now (a parameter may have changed since the sketch was
// last stored); one that cannot be evaluated is left as it was, to be replaced by this edit.
let _ = doc.apply_text_values(&doc.param_values().0, sk);
apply_text_edits(sk, doc, p)
})
}
/// One frame of an interactive resize or rotation: the sketch is solved and shown, the
/// features after it keep their last result until `sketch.drag_end` evaluates the model.
fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::LiveEdit>)> {
let cmd = "sketch.edit_text";
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let (out, report) = super::sketch::edit_solved(s, id, false, solidcraft_sketch::Analysis::Full, |sk, doc| {
Ok((apply_text_edits(sk, doc, p)?, super::sketch::Left::Unsolved))
})?;
@@ -487,6 +498,9 @@ fn joint_derivatives(sk: &Sketch, ci: usize, pi: usize, order: usize, leaving: b
fn blend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.blend_curve";
let order = blend_order(p, cmd)?;
if s.open_drawing.is_some() && order > 2 {
return Err(bad(cmd, "sheet sketches support G1/G2 blends; higher-order splines need their own bounded record contract"));
}
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let pa = req_parg(sk, doc, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?;
let pb = req_parg(sk, doc, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?;
@@ -866,6 +880,7 @@ fn tangent_curves(sk: &Sketch, p: &Value, cmd: &str, n: usize) -> Result<Vec<usi
/// A circle tangent to `curves`, solved from a start near `near`.
fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Option<String>) -> Result<Value> {
let near = req_vec2(cmd, p, "near")?;
let on_sheet = s.open_drawing.is_some();
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let cs = tangent_curves(sk, p, cmd, n)?;
let r0 = match &radius {
@@ -888,7 +903,7 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
cons.push(add_c(sk, ConstraintKind::Tangent { a: *k, b: c })?);
}
if let Some(e) = &radius {
let pname = doc.new_model_param(e, "mm");
let pname = if on_sheet { e.to_string() } else { doc.new_model_param(e, "mm") };
let k = sk.add_constraint(ConstraintKind::Radius { c, value: r0 }, Some(pname))?;
cons.push(k);
}
+9 -1
View File
@@ -9,6 +9,7 @@ use crate::{
pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.draw.dimensioned";
let on_sheet = s.open_drawing.is_some();
let create = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name a supported sketch creator"))?;
let roles: &[&str] = match create {
"sketch.line" => &["length", "angle"],
@@ -23,6 +24,10 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut params = p.get("params").filter(|v| v.is_object()).cloned().ok_or_else(|| bad(cmd, "`params` must be an object"))?;
let dimensions = p.get("dimensions").and_then(Value::as_object).ok_or_else(|| bad(cmd, "`dimensions` must be an object"))?;
if on_sheet {
// Never let a compound tool select another sheet or a model sketch.
super::super::sketch_store::sheet_target(s, &params, cmd)?;
}
if dimensions.len() > roles.len() || dimensions.keys().any(|key| !roles.contains(&key.as_str())) {
return Err(bad(cmd, "unsupported dimension role for this creator"));
}
@@ -106,7 +111,10 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
};
// A typed size the sketch already has (a rectangle drawn onto another's side, the same
// height) is placed as a driven (reference) dimension rather than refused.
let mut dimension = json!({"entities": entities, "type": ty, "value": value, "if_redundant": "driven"});
let mut dimension = json!({"entities": entities, "type": ty, "value": value});
if !on_sheet {
dimension["if_redundant"] = json!("driven");
}
if let Some(sketch) = params.get("sketch") {
dimension["sketch"] = sketch.clone();
}
+3 -2
View File
@@ -4,7 +4,8 @@
use serde_json::{Value, json};
use solidcraft_sketch::{ConstraintKind, EndReport, EndTouch, Sketch, loose_ends};
use super::sketch::{add_c, edit, target_sketch};
use super::sketch::{add_c, edit};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, string_list};
use crate::{EngineError, Result, Session};
@@ -53,7 +54,7 @@ fn report_json(sk: &Sketch, r: &EndReport) -> Value {
}
fn loose_ends_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let id = target_sketch(s, p, "sketch.loose_ends")?;
let id = sketch_store::target(s, p, "sketch.loose_ends")?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch has not been evaluated".into()))?;
Ok(report_json(&ss.sketch, &ss.ends))
+6 -9
View File
@@ -9,6 +9,7 @@ use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
use super::CommandSpec;
use super::sketch::{edit, ids_of};
use super::sketch_store;
use crate::params::{bad, num, str_, vec2};
use crate::{EngineError, Result, Session};
@@ -129,12 +130,13 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
Ok((ids_of(sk, &cs), texts))
})?;
// Text becomes sketch text.
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch"))?;
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
let mut n_text = 0;
for t in texts {
if let Geom2::Text { text, at, height, angle } = t {
// Not `sketch_store::update`: a text that can't be linked is skipped, not refused.
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
let mut sk = sketch_store::read(&doc, id)?.clone();
if super::sketch_project::add_link(
s,
&doc,
@@ -145,7 +147,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
)
.is_ok()
{
*doc.sketch_mut(id)? = sk;
sketch_store::write(&mut doc, id, sk)?;
*s.doc_mut() = doc;
n_text += 1;
}
@@ -164,12 +166,7 @@ fn insert_svg(s: &mut Session, p: &Value) -> Result<Value> {
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.export_dxf";
let id = match p.get("sketch") {
Some(Value::Number(n)) => n.as_u64(),
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
_ => s.active_sketch,
}
.ok_or_else(|| bad(cmd, "no such sketch (and none is active)"))?;
let id = sketch_store::target(s, p, cmd)?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
let sk = &ss.sketch;
+2 -12
View File
@@ -79,12 +79,7 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
let curves = string_list(p, "curves");
if !curves.is_empty() {
let id = match p.get("sketch") {
Some(Value::Number(n)) => n.as_u64(),
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
_ => s.active_sketch,
}
.ok_or_else(|| bad(cmd, "no such sketch (and none is active)"))?;
let id = super::sketch_store::target(s, p, cmd)?;
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
for c in curves.iter().take(1000) {
let ci = ss.sketch.curve_index(c).ok_or_else(|| bad(cmd, format!("unknown curve `{c}`")))?;
@@ -230,12 +225,7 @@ fn minimum_radius(s: &mut Session, p: &Value) -> Result<Value> {
let mut items: Vec<(String, Option<(f64, Vec3)>)> = Vec::new();
let curves = string_list(p, "curves");
if !curves.is_empty() {
let id = match p.get("sketch") {
Some(Value::Number(n)) => n.as_u64(),
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
_ => s.active_sketch,
}
.ok_or_else(|| bad(cmd, "no such sketch (and none is active)"))?;
let id = super::sketch_store::target(s, p, cmd)?;
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
for c in curves.iter().take(1000) {
let ci = ss.sketch.curve_index(c).ok_or_else(|| bad(cmd, format!("unknown curve `{c}`")))?;
+44 -21
View File
@@ -9,6 +9,7 @@ use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
use super::sketch::{add_c, edit, ids_of, solve_current};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
use crate::{Result, Session};
@@ -22,22 +23,22 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.fillet", "Fillet", fillet)
.at("SKETCH", "MODIFY")
.icon("sketch_fillet")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`)", created_doc!())),
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("point | a, b: a corner of two lines; distance", created_doc!())),
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer_angle")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("point | a, b: a corner of two lines; distance: along a, angle: deg from a", created_doc!())),
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
.at("SKETCH", "MODIFY")
.icon("sketch_chamfer_two_distances")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("point | a, b: a corner of two lines; distance: along a, distance2: along b", created_doc!())),
CommandSpec::new("sketch.offset", "Offset", offset)
.at("SKETCH", "MODIFY")
@@ -57,11 +58,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SKETCH", "MODIFY")
.icon("trim")
.key("T")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it)", created_doc!())),
CommandSpec::new("sketch.trim.path", "Trim Along Path", trim_path)
.icon("trim")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!(
"points: [[x,y]…] (2…2000 positions: a path drawn across the sketch). Every piece the path crosses is removed as `sketch.trim` removes the piece at that crossing, in the order the path crosses them; a piece crossed again after it is gone is not trimmed twice; projected geometry, infinite lines, offset outputs and pieces trim refuses are passed over (why in `skipped`). dry_run?: bool (report without changing the design). One undo step → trimmed: [each trim's result], removed: [[[x,y]…]] (each removed piece as a polyline)",
created_doc!()
@@ -69,27 +70,27 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("sketch.extend", "Extend", extend)
.at("SKETCH", "MODIFY")
.icon("extend")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params("curve: id, at: [x,y] near the end to extend (to the nearest curve)"),
CommandSpec::new("sketch.break", "Break", break_cmd)
.at("SKETCH", "MODIFY")
.icon("sketch_break")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("curve: id, at: [x,y] (split at intersections) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())),
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
.at("SKETCH", "MODIFY")
.icon("sketch_scale")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params("entities: [curve/point ids], base: [x,y] or point ref, factor: number"),
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
.at("SKETCH", "MODIFY")
.icon("sketch_move")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
CommandSpec::new("sketch.mirror", "Mirror", mirror)
.at("SKETCH", "PATTERN")
.icon("sketch_mirror")
.enabled(in_sketch)
.in_sketch_part().enabled(in_sketch)
.params(concat!("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)", created_doc!())),
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
.at("SKETCH", "PATTERN")
@@ -364,10 +365,13 @@ fn refuse_offset_ends(sk: &Sketch, ci: usize, what: &str, cmd: &str) -> Result<(
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.trim";
let at = req_vec2(cmd, p, "at")?;
let model_edit = s.open_drawing.is_none();
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
let (out, _) = remove_piece(sk, ci, at, cmd)?;
doc.prune_model_params();
if model_edit {
doc.prune_model_params();
}
Ok(out)
})?;
Ok(json!({"result": out, "sketch": info}))
@@ -522,6 +526,7 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
out["dry_run"] = json!(true);
return Ok(out);
}
let model_edit = s.open_drawing.is_none();
let ((trimmed, removed, skipped), info) = edit(s, p, cmd, false, |sk, doc| {
let mut trimmed: Vec<Value> = Vec::new();
let mut removed: Vec<Value> = Vec::new();
@@ -549,7 +554,9 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
if trimmed.is_empty() {
return Err(bad(cmd, skipped.first().cloned().unwrap_or_else(|| "the path crosses no curve that can be trimmed".into())));
}
doc.prune_model_params();
if model_edit {
doc.prune_model_params();
}
Ok((trimmed, removed, skipped))
})?;
Ok(json!({"trimmed": trimmed, "removed": removed, "skipped": skipped, "sketch": info}))
@@ -558,6 +565,7 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.break";
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None };
let model_edit = s.open_drawing.is_none();
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let ci = curve_arg(sk, p, cmd)?;
if sk.curves.get(ci).is_some_and(|c| c.kind.is_freeform()) {
@@ -567,7 +575,9 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let at = req_vec2(cmd, p, "at")?;
super::sketch_freeform::trim_or_break(sk, ci, at, false, cmd)?
};
doc.prune_model_params();
if model_edit {
doc.prune_model_params();
}
return Ok(r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default());
}
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
@@ -601,10 +611,15 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.extend";
let at = req_vec2(cmd, p, "at")?;
let on_sheet = s.open_drawing.is_some();
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
// The sheet receiver has already solved the exact paper-owned sketch.
// Model-only parameter evaluation would discard sheet expressions.
if !on_sheet {
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
}
}
let ci = curve_arg(sk, p, cmd)?;
refuse_offset_ends(sk, ci, "extend", cmd)?;
@@ -956,12 +971,14 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
Some(_) => return Err(bad(cmd, "`points` must list 1…500 corners")),
None => vec![p.clone()],
};
let on_sheet = s.open_drawing.is_some();
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
let r = doc.eval(&r_expr, solidcraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?;
if !(r > 1e-9 && r < 1e8) {
return Err(bad(cmd, "radius must be positive"));
}
let pname = doc.new_model_param(&r_expr, "mm");
// A sheet owns its radius expression, not a new source-model parameter.
let pname = if on_sheet { r_expr.to_string() } else { doc.new_model_param(&r_expr, "mm") };
let (mut arcs, mut cons, mut kept) = (Vec::new(), Vec::new(), Vec::new());
for q in &corners {
let (arc, mut c, k) = fillet_corner(sk, q, r, cmd)?;
@@ -977,7 +994,13 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
Ok((ids_of(sk, &arcs), cons, pname, kept_json(kept)))
})?;
let (vc, moved) = out.3;
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
let param = if on_sheet { Value::Null } else { json!(out.2) };
let mut result =
json!({"curves": out.0, "constraints": out.1, "param": param, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info});
if on_sheet {
result["expression"] = json!(r_expr);
}
Ok(result)
}
/// The virtual corners kept and the constraints moved to them, for a response.
@@ -1655,7 +1678,7 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.offset.break";
let sketch = super::sketch::target_sketch(s, p, cmd)?;
let sketch = sketch_store::target(s, p, cmd)?;
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
let output = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must name an offset output"))?;
let record = sk.freeform_offsets.iter().position(|record| record.output == output);
@@ -1675,7 +1698,7 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "cannot break an offset with conflicting dimensions".into())));
}
sk.validate_periodic_splines()?;
sketch_store::checked(sk)?;
if let Some(association) = sk
.offsets
.iter()
+82 -68
View File
@@ -9,6 +9,7 @@ use solidcraft_doc::{Document, FeatureKind};
use solidcraft_geom::{Plane, Vec3};
use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
use super::sketch_store;
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, str_, vec3};
use crate::{EngineError, Result, Session};
@@ -196,7 +197,7 @@ pub(super) fn refresh(s: &Session, doc: &Document, id: u64, sk: &mut Sketch) ->
Some(anchor) => solidcraft_doc::project::refresh_face_anchor_links(&doc, &vals, &st, &plane, sk, anchor),
None => solidcraft_doc::project::refresh_links(&doc, &vals, &st, &plane, sk),
};
if let Ok(stored) = doc.sketch(id)
if let Ok(stored) = sketch_store::read(&doc, id)
&& sk.positions_in(stored).is_some()
{
let mut solved = sk.clone();
@@ -254,19 +255,18 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind) -> Result<Va
if refs.is_empty() {
return Err(bad(cmd, "`refs` is empty"));
}
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let sources: Vec<LinkSource> = refs.iter().map(|v| source(s, v, cmd)).collect::<Result<_>>()?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
refresh(s, &doc, id, &mut sk);
let mut out = Vec::new();
for src in sources {
let what = src.describe();
let l = add_link(s, &doc, id, &mut sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
out.push(link_json(&sk, &l));
}
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
let out = sketch_store::update(s, id, |s, doc, sk| {
refresh(s, doc, id, sk);
let mut out = Vec::new();
for src in sources {
let what = src.describe();
let l = add_link(s, doc, id, sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
out.push(link_json(sk, &l));
}
Ok(out)
})?;
Ok(json!({"links": out}))
}
@@ -284,18 +284,16 @@ fn include_cmd(s: &mut Session, p: &Value) -> Result<Value> {
/// Add one link from `src` to the active sketch and report it.
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
refresh(s, &doc, id, &mut sk);
let what = src.describe();
let l = add_link(s, &doc, id, &mut sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
let mut out = link_json(&sk, &l);
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
out["wires"] = json!(wires);
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
Ok(out)
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
sketch_store::update(s, id, |s, doc, sk| {
refresh(s, doc, id, sk);
let what = src.describe();
let l = add_link(s, doc, id, sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
let mut out = link_json(sk, &l);
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
out["wires"] = json!(wires);
Ok(out)
})
}
fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
@@ -338,7 +336,7 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
"Y" => (Vec3::ZERO, Vec3::Y),
"Z" => (Vec3::ZERO, Vec3::Z),
_ => {
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
let ci = ss.sketch.curve_index(axis).ok_or_else(|| bad(cmd, format!("unknown curve `{axis}`")))?;
@@ -371,48 +369,47 @@ fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let mut doc = (*s.doc).clone();
let mut sk = doc.sketch(id)?.clone();
let l = add_link(s, &doc, id, &mut sk, LinkKind::Intersect, LinkSource::Body { body }).map_err(|e| bad(cmd, e.to_string()))?;
let curves: Vec<String> = sk.link_curves(&l).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
sk.break_link(&l)?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let curves = sketch_store::update(s, id, |s, doc, sk| {
let l = add_link(s, doc, id, sk, LinkKind::Intersect, LinkSource::Body { body }).map_err(|e| bad(cmd, e.to_string()))?;
let curves: Vec<String> = sk.link_curves(&l).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
sk.break_link(&l)?;
Ok(curves)
})?;
Ok(json!({"curves": curves}))
}
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.break_link";
let id = s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let mut doc = (*s.doc).clone();
let sk = doc.sketch_mut(id)?;
let mut links: Vec<String> = Vec::new();
if let Some(l) = str_(p, "link") {
if sk.link(l).is_none() {
return Err(bad(cmd, format!("no link `{l}`")));
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
let links = sketch_store::update(s, id, |_, _, sk| {
let mut links: Vec<String> = Vec::new();
if let Some(l) = str_(p, "link") {
if sk.link(l).is_none() {
return Err(bad(cmd, format!("no link `{l}`")));
}
links.push(l.to_string());
}
links.push(l.to_string());
}
for e in crate::params::string_list(p, "entities") {
let l = sk
.curves
.iter()
.find(|c| c.id == e)
.and_then(|c| c.link.clone())
.or_else(|| sk.points.iter().find(|q| q.id == e).and_then(|q| q.link.clone()))
.ok_or_else(|| bad(cmd, format!("`{e}` is not linked geometry")))?;
if !links.contains(&l) {
links.push(l);
for e in crate::params::string_list(p, "entities") {
let l = sk
.curves
.iter()
.find(|c| c.id == e)
.and_then(|c| c.link.clone())
.or_else(|| sk.points.iter().find(|q| q.id == e).and_then(|q| q.link.clone()))
.ok_or_else(|| bad(cmd, format!("`{e}` is not linked geometry")))?;
if !links.contains(&l) {
links.push(l);
}
}
}
if links.is_empty() {
return Err(bad(cmd, "give `link` or `entities`"));
}
for l in &links {
sk.break_link(l)?;
}
*s.doc_mut() = doc;
if links.is_empty() {
return Err(bad(cmd, "give `link` or `entities`"));
}
for l in &links {
sk.break_link(l)?;
}
Ok(links)
})?;
Ok(json!({"broken": links}))
}
@@ -427,10 +424,25 @@ fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
/// makes a point held at the line's midpoint (and `"vertex:x,y,z"` is the vertex form). Everything
/// else is left as it is. The links are added to the active sketch in the session's document.
pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let Some(id) = s.active_sketch else { return Ok(p.clone()) };
// A sheet may be open while the model retains its own active sketch. Never
// project into that model or reinterpret its new local ids as sheet points.
let sheet = s.open_drawing.is_some();
let target = if sheet { None } else { sketch_store::active(s) };
if !sheet && target.is_none() {
return Ok(p.clone());
}
let mut out = p.clone();
let mut doc: Option<(Document, Sketch)> = None;
let fix = |v: &mut Value, s: &Session, doc: &mut Option<(Document, Sketch)>| -> Result<()> {
// These spellings denote automatic projection/snap operations, not an
// authored 2D point. Reject even malformed values with an `at` fallback.
if sheet
&& (v.get("vertex").is_some()
|| v.get("on_edge").is_some()
|| v.as_str().is_some_and(|text| ["vertex:", "mid:", "on:", "x:"].iter().any(|prefix| text.starts_with(prefix))))
{
return Err(bad(cmd, "not supported yet: automatic snap or projection in a sheet sketch; use a 2D point or exact local point id"));
}
let (vertex, edge) = (v.get("vertex").and_then(vec3), v.get("on_edge").and_then(vec3));
// String forms from interactive snapping: "vertex:x,y,z" and "mid:<line>".
let vertex = vertex.or_else(|| {
@@ -454,9 +466,10 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
if vertex.is_none() && edge.is_none() && mid.is_none() && on.is_none() {
return Ok(());
}
let id = target.ok_or_else(|| bad(cmd, "no model sketch is being edited"))?;
if doc.is_none() {
let d = (*s.doc).clone();
let mut sk = d.sketch(id)?.clone();
let mut sk = sketch_store::read(&d, id)?.clone();
refresh(s, &d, id, &mut sk);
*doc = Some((d, sk));
}
@@ -511,7 +524,8 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
}
}
if let Some((mut d, sk)) = doc {
*d.sketch_mut(id)? = sk;
let id = target.ok_or_else(|| bad(cmd, "no model sketch is being edited"))?;
sketch_store::write(&mut d, id, sk)?;
*s.doc_mut() = d;
}
Ok(out)
@@ -535,7 +549,7 @@ pub(super) fn project_internal_faces(s: &mut Session, id: u64, body: &str, indic
let plane = sketch_plane(&doc, id, &state)?;
let names = state.body(body).map(solidcraft_doc::naming::face_names).unwrap_or_default();
let mut sk = doc.sketch(id)?.clone();
let mut sk = sketch_store::read(&doc, id)?.clone();
let mut links = Vec::with_capacity(indices.len());
for &index in indices {
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
@@ -545,7 +559,7 @@ pub(super) fn project_internal_faces(s: &mut Session, id: u64, body: &str, indic
}
links.push(link);
}
*doc.sketch_mut(id)? = sk;
sketch_store::write(&mut doc, id, sk)?;
*s.doc_mut() = doc;
Ok(links)
}
@@ -568,13 +582,13 @@ pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<O
let state = solidcraft_doc::state_in_face_anchor_frame(&doc, &raw, &anchor, author)?;
let plane = doc.resolve_face_anchor(&anchor, author, &raw)?;
let resolved = solidcraft_doc::project::resolve_anchored_face(&doc, &state, &plane, LinkKind::Project, &anchor)?;
let mut sk = doc.sketch(id)?.clone();
let mut sk = sketch_store::read(&doc, id)?.clone();
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
if let Some(link) = sk.links.iter_mut().find(|entry| entry.id == link) {
link.face_name = Some(anchor.target.name.clone());
link.auto_face = true;
}
*doc.sketch_mut(id)? = sk;
sketch_store::write(&mut doc, id, sk)?;
*s.doc_mut() = doc;
Ok(Some(link))
}
+251
View File
@@ -0,0 +1,251 @@
//! The one door the sketch commands go through to find and keep the sketch they work on (W38.0,
//! #5425). Today every sketch is a design feature; a drawing session (W38a/b) adds sheet and view
//! sketches behind these same calls, so the commands don't change again. A source scan
//! (`each_sketch_command_goes_through_the_store`) keeps every sketch read and write in the
//! commands here, apart from the design's own, each marked `// design-only:`.
use serde_json::Value;
use solidcraft_doc::{Document, FeatureKind};
use solidcraft_sketch::Sketch;
use crate::params::bad;
use crate::{EngineError, Result, Session};
/// The sketch a command acts on: its `sketch` param, or the sketch being edited (also when
/// `sketch` is null, as callers send an optional value they don't have). A number is an exact id;
/// text is an id written out or a name, so a sketch named "7" is reached only as text.
pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
if let Some(v) = p.get("sketch").filter(|v| !v.is_null()) {
let (key, found) = match v {
Value::Number(n) => {
let id = n.as_u64().ok_or_else(|| bad(cmd, "`sketch` must be an id or a name"))?;
(id.to_string(), s.doc.feature(id))
}
Value::String(x) => (x.clone(), s.doc.find_feature(x)),
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
};
let f = found.ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
return Err(bad(cmd, format!("`{key}` is not a sketch")));
}
return Ok(f.id);
}
s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into()))
}
/// The sketch being edited, if any; each command keeps its own refusal when there is none.
pub(super) fn active(s: &Session) -> Option<u64> {
s.active_sketch
}
/// Sketch `id`, to read.
pub(super) fn read(doc: &Document, id: u64) -> Result<&Sketch> {
Ok(doc.sketch(id)?)
}
/// Read the selected sketch for a command that needs an owned preflight before editing.
/// Sheet identities are resolved against the open Drawing, never the model feature ids;
/// resolving paper expressions and solving here does not publish either document.
pub(super) fn read_for_command(s: &Session, p: &Value, cmd: &str) -> Result<Sketch> {
if s.open_drawing.is_some() {
let entry = sheet_target(s, p, cmd)?;
return Ok(s.drawing(cmd)?.solve_sheet_sketch(&s.doc, entry.sketch)?.sketch);
}
Ok(read(&s.doc, target(s, p, cmd)?)?.clone())
}
/// Keep `sk` as sketch `id`.
pub(super) fn write(doc: &mut Document, id: u64, sk: Sketch) -> Result<()> {
*doc.sketch_mut(id)? = sk;
Ok(())
}
/// Change sketch `id` on a copy of the document, then keep both: the link and text commands'
/// edit. As those commands always have, it neither solves the sketch nor runs [`checked`].
pub(super) fn update<T>(s: &mut Session, id: u64, f: impl FnOnce(&Session, &Document, &mut Sketch) -> Result<T>) -> Result<T> {
let mut doc = (*s.doc).clone();
let mut sk = read(&doc, id)?.clone();
let out = f(s, &doc, &mut sk)?;
write(&mut doc, id, sk)?;
*s.doc_mut() = doc;
Ok(out)
}
/// The checks an edited sketch passes before it is kept.
pub(super) fn checked(sk: &Sketch) -> Result<()> {
sk.validate_periodic_splines()?;
Ok(())
}
/// The exact active sheet target, checked before a shared tool reads or edits it.
/// A number/string can confirm that target; it never selects a model or another sheet.
pub(super) fn sheet_target(s: &Session, p: &Value, cmd: &str) -> Result<std::sync::Arc<crate::SheetSketchEdit>> {
let entry = s.active_sheet_sketch.as_ref().ok_or_else(|| bad(cmd, "no sheet sketch is being edited"))?;
if s.open_drawing != Some(entry.drawing) {
return Err(bad(cmd, "the edited sketch does not belong to the open drawing"));
}
let mut owners = s.doc.drawings.iter().filter(|drawing| drawing.id == entry.drawing);
let owner = owners.next().ok_or_else(|| bad(cmd, "the edited drawing no longer exists"))?;
if owners.next().is_some() {
return Err(bad(cmd, "the edited drawing id is ambiguous"));
}
// Target selection checks identity, not every payload. Staging checks the
// candidate before solving; edit_drawing validates the whole publication.
if owner.sketches.iter().filter(|record| record.id == entry.sketch).count() != 1
|| owner.sheets.iter().filter(|sheet| sheet.id == entry.sheet).count() != 1
{
return Err(bad(cmd, "the edited sketch or sheet id is missing or ambiguous"));
}
let record = owner.sheet_sketch(entry.sketch).ok_or_else(|| bad(cmd, "the edited sheet sketch no longer exists"))?;
if record.sheet != entry.sheet {
return Err(bad(cmd, "the edited sketch's owning sheet changed"));
}
if let Some(given) = p.get("sketch").filter(|value| !value.is_null()) {
let target = match given {
Value::Number(number) => number.as_u64(),
Value::String(text) => solidcraft_doc::ids::decode(text),
_ => None,
}
.ok_or_else(|| bad(cmd, "a sheet sketch target must be its exact id"))?;
if target != entry.sketch {
return Err(bad(cmd, "finish or cancel before selecting a different sheet sketch"));
}
}
Ok(entry.clone())
}
/// Apply an existing geometry tool through R2. The legacy callback may read its
/// Document, but any attempted source mutation is refused before publication.
/// This is the sheet receiver of this store, not a second store or solver.
pub(super) fn edit_sheet<T>(
s: &mut Session,
p: &Value,
cmd: &str,
change: impl FnOnce(&mut Sketch, &mut Document) -> Result<T>,
) -> Result<(T, Value)> {
let entry = sheet_target(s, p, cmd)?;
let design = s.doc.clone();
let mut candidate = s.drawing(cmd)?.solve_sheet_sketch(&design, entry.sketch)?.sketch;
let mut source = (*design).clone();
let out = change(&mut candidate, &mut source)?;
if source != *design {
return Err(bad(cmd, "a sheet sketch cannot change the source design or its parameters"));
}
let solved = s.edit_drawing(cmd, |drawing| {
let (_, solved) = drawing.edit_sheet_sketch(&design, entry.sketch, |sketch| {
*sketch = candidate;
Ok(())
})?;
if solved.sheet != entry.sheet {
return Err(bad(cmd, "the edited sketch's owning sheet changed"));
}
Ok(solved)
})?;
Ok((out, super::sketch::report_json(&solved.report)))
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use crate::Session;
/// No `sketch`, or a null one, is the sketch being edited; anything else that isn't an id or
/// a name is refused (#6336: sketch.inspect's callers send null for "none given").
#[test]
fn target_reads_a_null_sketch_as_none_given() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let active = s.active_sketch.unwrap();
for p in [json!({}), json!({"sketch": null})] {
assert_eq!(super::target(&s, &p, "test").unwrap(), active, "{p}");
}
assert!(super::target(&s, &json!({"sketch": true}), "test").is_err());
}
/// A number is an exact id, never a name (0xlillith on #6336): a sketch named "999999" isn't
/// sketch 999999, so a command given that number refuses instead of picking it, and the name
/// as text still finds it. Through the commands that read only ids before W38.0.
#[test]
fn target_reads_a_number_as_an_id_and_never_a_name() {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane": "XY", "name": "999999"})).unwrap();
let named = s.active_sketch.unwrap();
assert_ne!(named, 999999);
let c = s.execute("sketch.circle.center", &json!({"center": [0, 0], "radius": 5})).unwrap()["curves"][0].clone();
s.execute("sketch.finish", &json!({})).unwrap();
// Glyphs and the curvature inspects need a sketch being edited; it's another one.
s.execute("sketch.create", &json!({"plane": "XZ"})).unwrap();
for (cmd, p) in [
("sketch.export_dxf", json!({})),
("sketch.glyphs", json!({})),
("inspect.curvature_comb", json!({"curves": [c]})),
("inspect.minimum_radius", json!({"curves": [c]})),
] {
let with = |sketch: Value| {
let mut p = p.clone();
p["sketch"] = sketch;
p
};
let e = s.execute(cmd, &with(json!(999999))).unwrap_err().to_string();
assert!(e.contains("no sketch `999999`"), "{cmd}: {e}");
s.execute(cmd, &with(json!("999999"))).unwrap_or_else(|e| panic!("{cmd} by name: {e}"));
s.execute(cmd, &with(json!(named))).unwrap_or_else(|e| panic!("{cmd} by id: {e}"));
for bad in [json!(-1), json!(1.5)] {
let e = s.execute(cmd, &with(bad.clone())).unwrap_err().to_string();
assert!(e.contains("must be an id or a name"), "{cmd} {bad}: {e}");
}
}
assert_eq!(super::target(&s, &json!({"sketch": "999999"}), "test").unwrap(), named);
assert_eq!(super::target(&s, &json!({"sketch": named.to_string()}), "test").unwrap(), named);
}
/// Each sketch command finds and keeps its sketch through this store (W38.0). Outside it, the
/// command sources (non-test code) reach a sketch directly only on a line marked
/// `// design-only:` in the files that work on the design's own sketches.
#[test]
fn each_sketch_command_goes_through_the_store() {
// Sketches read or written, and the sketch being edited taken as a value (let-else, if let,
// a match arm, `and_then`, `map`, `ok_or`); its state checks, assignments and snapshot copies
// stay allowed.
const PATTERNS: [&str; 8] = [
".sketch_mut(",
"doc.sketch(",
" d.sketch(",
"active_sketch.ok_or",
") = s.active_sketch",
"=> s.active_sketch",
"s.active_sketch.and_then(",
"s.active_sketch.map(",
];
// A motion trace's wire, a joint's curve and Redefine Sketch Plane: the design's sketches.
const DESIGN_ONLY: [&str; 3] = ["browser.rs", "joints_inspect.rs", "motion_trace.rs"];
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/cmd")];
let (mut offenders, mut scanned) = (Vec::new(), 0);
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string();
if path.is_dir() {
stack.push(path);
continue;
}
if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "sketch_store.rs" {
continue;
}
scanned += 1;
// A Windows checkout's CRLF lines, read as written, so the split below finds the tests.
let text = std::fs::read_to_string(&path).unwrap().replace("\r\n", "\n");
let lines: Vec<&str> = text.split("\n#[cfg(test)]\nmod ").next().unwrap_or_default().lines().collect();
for (n, line) in lines.iter().enumerate() {
let marked = n > 0 && lines[n - 1].trim_start().starts_with("// design-only:") && DESIGN_ONLY.contains(&name.as_str());
if !line.trim_start().starts_with("//") && !marked && PATTERNS.iter().any(|p| line.contains(p)) {
offenders.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
}
}
}
}
assert!(scanned > 50, "the command sources were scanned ({scanned} files)");
assert!(offenders.is_empty(), "a sketch read or written outside cmd::sketch_store; go through it:\n{}", offenders.join("\n"));
}
}
+128 -16
View File
@@ -157,13 +157,43 @@ struct BodyRename {
new: String,
}
/// A sheet-sketch editing entry. Its owner is never a model feature id.
/// Original geometry is only the cancel boundary; Session keeps the one undo history.
#[derive(Clone, Debug, PartialEq)]
pub struct SheetSketchEdit {
pub drawing: u64,
pub sheet: u64,
pub sketch: u64,
pub(crate) original: Option<Arc<solidcraft_doc::drawing::SheetSketch>>,
}
/// One undo step.
#[derive(Clone, Debug)]
pub struct Snapshot {
pub label: String,
pub doc: Arc<Document>,
pub active_sketch: Option<u64>,
/// Explicitly owned sheet edit, independent of the model sketch being edited.
pub active_sheet_sketch: Option<Arc<SheetSketchEdit>>,
pub pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
/// The part of the design the step changed: a drawing (by id), or the model and the list of
/// drawings (`None`). Undo and redo go through one part's steps (`edit.undo`).
pub part: Option<u64>,
}
impl Snapshot {
/// The session as it is now, as a step of the model, under `label`: the one place a step is
/// taken.
pub fn of(s: &Session, label: &str) -> Snapshot {
Snapshot {
label: label.to_string(),
doc: s.doc.clone(),
active_sketch: s.active_sketch,
active_sheet_sketch: s.active_sheet_sketch.clone(),
pending_moves: s.pending_moves.clone(),
part: None,
}
}
}
/// A copy of a session that evaluates commands ahead of it ([`Session::ahead`]).
@@ -243,6 +273,11 @@ pub struct Session {
pub redo: Vec<Snapshot>,
/// Sketch feature being edited (sketch mode).
pub active_sketch: Option<u64>,
/// Explicitly owned sheet edit, independent of the model sketch being edited.
pub active_sheet_sketch: Option<Arc<SheetSketchEdit>>,
/// The drawing open (by id): drawing commands change it, and the model's wait until it is
/// closed ([`cmd::Part`]).
pub open_drawing: Option<u64>,
pub selection: Vec<Sel>,
pub path: Option<String>,
/// One-shot display-only notification during doc.open, before authoritative evaluation.
@@ -321,12 +356,15 @@ pub struct LiveEdit {
pub report: solidcraft_sketch::SolveReport,
}
/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`.
/// The design, active sketch, open drawing, pending positions and undo history at an `edit.checkpoint`.
#[derive(Clone, Debug)]
pub(crate) struct Checkpoint {
pub id: u64,
pub doc: Arc<Document>,
pub active_sketch: Option<u64>,
/// Explicitly owned sheet edit, independent of the model sketch being edited.
pub active_sheet_sketch: Option<Arc<SheetSketchEdit>>,
pub open_drawing: Option<u64>,
pub pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
pub undo: Vec<Snapshot>,
pub redo: Vec<Snapshot>,
@@ -415,6 +453,8 @@ impl Session {
undo: Vec::new(),
redo: Vec::new(),
active_sketch: None,
active_sheet_sketch: None,
open_drawing: None,
selection: Vec::new(),
path: None,
open_preview: None,
@@ -530,6 +570,20 @@ impl Session {
Ok(())
}
/// An undo or document replacement cannot leave an edit aimed at another owner.
pub(crate) fn prune_sheet_sketch_edit(&mut self) {
if self.active_sheet_sketch.as_ref().is_some_and(|entry| {
self.open_drawing != Some(entry.drawing)
|| self
.doc
.drawing(entry.drawing)
.and_then(|drawing| drawing.sheet_sketch(entry.sketch))
.is_none_or(|record| record.sheet != entry.sheet)
}) {
self.active_sheet_sketch = None;
}
}
/// The design as a file holds it: the document with what is hidden in it.
pub fn design(&self) -> std::borrow::Cow<'_, Document> {
if self.visibility.is_empty() {
@@ -557,6 +611,28 @@ impl Session {
Arc::make_mut(&mut self.doc)
}
/// The drawing open, for `cmd`.
pub fn drawing(&self, cmd: &str) -> Result<&Arc<solidcraft_doc::drawing::Drawing>> {
self.open_drawing.and_then(|id| self.doc.drawing(id)).ok_or_else(|| params::bad(cmd, "open a drawing first (drawing.open)"))
}
/// Change the drawing open, for `cmd`: kept when it still keeps the record's rules, and only
/// when it changed (one undo step of the drawing, through `execute`; the design's other
/// drawings shared).
pub fn edit_drawing<T>(&mut self, cmd: &str, change: impl FnOnce(&mut solidcraft_doc::drawing::Drawing) -> Result<T>) -> Result<T> {
let open = self.drawing(cmd)?;
let mut d = (**open).clone();
let out = change(&mut d)?;
if d.id != open.id {
return Err(params::bad(cmd, "a drawing keeps its id"));
}
d.validate().map_err(|e| params::bad(cmd, e.to_string()))?;
if d != **open {
self.doc_mut().put_drawing(Arc::new(d));
}
Ok(out)
}
/// Prepared placements only: optional save caches must not compute a cold world.
pub(crate) fn cached_world_state(&self) -> Option<Arc<solidcraft_doc::ModelState>> {
let state = self.model.state();
@@ -649,6 +725,7 @@ impl Session {
}
self.revision += 1;
self.prune_occurrence_state();
self.prune_sheet_sketch_edit();
// Joints follow the geometry their origins snap to.
if !self.doc.assembly.joints.is_empty() {
cmd::joints::resolve(self);
@@ -708,12 +785,14 @@ impl Session {
Some((old, new))
}
/// Only persistent render intent, or only part properties, changed. Never evaluate or clear
/// a stale model here.
/// Only persistent render intent, part properties or drawings changed. Never evaluate or
/// clear a stale model here.
fn render_only_change(&self, before: &Snapshot) -> bool {
self.active_sketch == before.active_sketch
&& self.pending_moves == before.pending_moves
&& (self.doc.differs_only_in_render(&before.doc) || self.doc.differs_only_in_part_properties(&before.doc))
&& (self.doc.differs_only_in_render(&before.doc)
|| self.doc.differs_only_in_part_properties(&before.doc)
|| self.doc.differs_only_in_drawings(&before.doc))
}
fn refresh_render(&mut self, previous: &Arc<Document>) {
@@ -731,6 +810,7 @@ impl Session {
/// Show the sketch a live frame changed, leaving the features after it as they were last
/// evaluated (marked stale); a full `refresh` when the model cannot show it that way.
fn refresh_live(&mut self, edit: LiveEdit) {
// design-only: a live drag shows the design's sketch in the model.
let sketch = self.doc.sketch(edit.sketch).ok().cloned();
if sketch.is_some_and(|sketch| self.model.show_live_sketch(edit.sketch, sketch, edit.report)) {
self.revision += 1;
@@ -786,16 +866,19 @@ impl Session {
let host_viewport = self.render_viewport.clone();
let host_jobs = self.render_jobs.host.clone();
let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true));
if live.is_none() && spec.reads_model && self.model.is_stale() {
// A disabled model command must not evaluate a stale model merely because
// it was tried from a drawing. Shared sheet tools read only drawing data.
if self.open_drawing.is_some() {
spec.allowed(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
}
let reads_model = spec.reads_model && !(self.open_drawing.is_some() && spec.part == cmd::Part::Sketch);
if live.is_none() && reads_model && self.model.is_stale() {
self.refresh();
}
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let before = Snapshot {
label: spec.label.to_string(),
doc: self.doc.clone(),
active_sketch: self.active_sketch,
pending_moves: self.pending_moves.clone(),
};
spec.allowed(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let open_drawing = self.open_drawing;
let before =
Snapshot { part: open_drawing.filter(|_| matches!(spec.part, cmd::Part::Drawing | cmd::Part::Sketch)), ..Snapshot::of(self, spec.label) };
let null = Value::Object(Default::default());
let params = if params.is_null() { &null } else { params };
let given = params;
@@ -833,7 +916,9 @@ impl Session {
// Commands that only read leave no trace (the journal keeps what a replay needs).
let changed = !Arc::ptr_eq(&self.doc, &before.doc)
|| self.active_sketch != before.active_sketch
|| self.active_sheet_sketch != before.active_sheet_sketch
|| self.pending_moves != before.pending_moves
|| self.open_drawing != open_drawing
|| spec.id.starts_with("select.");
crash::command_done(self.crash_journal, changed, &before.doc, after, spec.id, given, failed.map_or(Ok(()), Err));
}
@@ -845,11 +930,14 @@ impl Session {
let render_only = self.render_only_change(&before);
let previous_doc = before.doc.clone();
if spec.undoable {
// A new step ends what could be redone in its part; the other parts' redo
// steps still hold, as undo puts back only the part a step changed.
let part = before.part;
self.undo.push(before);
if self.undo.len() > MAX_UNDO {
self.undo.remove(0);
}
self.redo.clear();
self.redo.retain(|step| step.part != part);
}
match live_edit {
Some(edit) => self.refresh_live(edit),
@@ -860,6 +948,8 @@ impl Session {
Ok(v)
}
Err(e) => {
self.open_drawing = open_drawing;
self.active_sheet_sketch = before.active_sheet_sketch.clone();
if changed || self.pending_moves != before.pending_moves {
self.doc = before.doc;
self.active_sketch = before.active_sketch;
@@ -881,18 +971,32 @@ impl Session {
return v;
}
let Some(out) = v.as_object_mut() else { return v };
if self.open_drawing.is_some() {
if let Some(entry) =
self.active_sheet_sketch.as_ref().or(before.active_sheet_sketch.as_ref()).filter(|entry| self.open_drawing == Some(entry.drawing))
{
let current = self.doc.drawing(entry.drawing).and_then(|drawing| drawing.sheet_sketch(entry.sketch));
if let Some(after) = current.filter(|record| record.sheet == entry.sheet) {
let previous = before.doc.drawing(entry.drawing).and_then(|drawing| drawing.sheet_sketch(entry.sketch));
out.insert("created".into(), cmd::sketch_created(previous.map(|record| &record.sketch), &after.sketch));
}
}
return v; // never fall back to the model after a sheet cancellation
}
let named = match params.get("sketch") {
Some(Value::Number(n)) => self.doc.find_feature(&n.to_string()).map(|f| f.id),
Some(Value::String(x)) => self.doc.find_feature(x).map(|f| f.id),
_ => None,
};
let Some(sk) = named.or(self.active_sketch).or(before.active_sketch) else { return v };
// design-only: a command's `created` compares two snapshots of the design.
let Ok(after) = self.doc.sketch(sk) else { return v };
out.insert("created".into(), cmd::sketch_created(before.doc.sketch(sk).ok(), after));
v
}
/// Remember the design, active sketch, pending positions and undo history; returns the checkpoint's id.
/// Remember the design, active sketch, open drawing, pending positions and undo history;
/// returns the checkpoint's id.
pub fn checkpoint(&mut self) -> u64 {
let id = self.next_checkpoint;
self.next_checkpoint += 1;
@@ -900,6 +1004,8 @@ impl Session {
id,
doc: self.doc.clone(),
active_sketch: self.active_sketch,
active_sheet_sketch: self.active_sheet_sketch.clone(),
open_drawing: self.open_drawing,
undo: self.undo.clone(),
pending_moves: self.pending_moves.clone(),
redo: self.redo.clone(),
@@ -915,14 +1021,16 @@ impl Session {
pub fn restore_checkpoint(&mut self, id: u64) -> Result<()> {
let i = self.checkpoints.iter().position(|c| c.id == id).ok_or_else(|| EngineError::Other(format!("no checkpoint {id}")))?;
let Some(c) = self.checkpoints.drain(i..).next() else { return Err(EngineError::Other(format!("no checkpoint {id}"))) };
let before =
Snapshot { label: String::new(), doc: self.doc.clone(), active_sketch: self.active_sketch, pending_moves: self.pending_moves.clone() };
let before = Snapshot::of(self, "");
self.doc = c.doc;
self.active_sketch = c.active_sketch;
self.active_sheet_sketch = c.active_sheet_sketch;
self.open_drawing = c.open_drawing;
self.pending_moves = c.pending_moves;
self.undo = c.undo;
self.redo = c.redo;
self.selection.clear();
self.prune_sheet_sketch_edit();
if self.render_only_change(&before) {
self.refresh_render(&before.doc);
} else if !Arc::ptr_eq(&self.doc, &before.doc) || self.active_sketch != before.active_sketch || self.pending_moves != before.pending_moves {
@@ -947,6 +1055,8 @@ impl Session {
undo: Vec::new(),
redo: Vec::new(),
active_sketch: self.active_sketch,
active_sheet_sketch: self.active_sheet_sketch.clone(),
open_drawing: self.open_drawing,
selection: self.selection.clone(),
path: None,
open_preview: None,
@@ -1015,6 +1125,8 @@ impl Session {
}
self.redo = computed.redo;
self.active_sketch = computed.active_sketch;
self.active_sheet_sketch = computed.active_sheet_sketch;
self.open_drawing = computed.open_drawing;
self.selection = computed.selection;
self.revision = computed.revision;
self.invalidate_view(previous_revision);
@@ -0,0 +1,57 @@
//! Input/output contracts for the first sheet-sketch command lifecycle.
use super::TypedContract;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Create {
/// Exact sheet id in the open drawing, as returned by drawing.inspect.
pub owner: String,
pub name: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Edit {
pub sketch: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Inspect {
pub sketch: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Empty {}
#[derive(Serialize, JsonSchema)]
pub struct Point {
pub id: String,
pub at: [f64; 2],
pub fixed: bool,
}
#[derive(Serialize, JsonSchema)]
pub struct Reading {
pub drawing: String,
pub sheet: String,
pub sketch: String,
pub name: String,
pub editing: bool,
pub solved: bool,
pub dof: usize,
pub points: Vec<Point>,
pub curves: Vec<String>,
pub constraints: Vec<String>,
}
#[derive(Serialize, JsonSchema)]
pub struct Cancelled {
pub drawing: String,
pub sheet: String,
pub sketch: String,
pub removed: bool,
}
pub static CREATE: TypedContract<Create> = TypedContract::new();
pub static EDIT: TypedContract<Edit> = TypedContract::new();
pub static INSPECT: TypedContract<Inspect> = TypedContract::new();
pub static EMPTY: TypedContract<Empty> = TypedContract::new();
+1
View File
@@ -46,6 +46,7 @@ mod types;
mod tests;
// The contracts, by command file.
pub mod drawing_sketch;
pub mod features;
pub mod inspect;
+2 -2
View File
@@ -892,12 +892,12 @@ fn only_own_ids_name_commands() {
}
/// An unknown command's error names the ids it was probably meant to be: a typo's, or the
/// command that ends in the same word.
/// commands that end in the same word, the nearest first.
#[test]
fn unknown_commands_suggest_the_closest_ids() {
let mut s = Session::default();
let mut msg = |id: &str| s.execute(id, &serde_json::json!({})).unwrap_err().to_string();
assert_eq!(msg("file.open"), "unknown command `file.open` (did you mean `doc.open`?)");
assert!(msg("file.open").starts_with("unknown command `file.open` (did you mean `doc.open`"), "{}", msg("file.open"));
assert!(msg("solid.extrud").starts_with("unknown command `solid.extrud` (did you mean `solid.extrude`"), "{}", msg("solid.extrud"));
assert!(msg("Extrude").contains("`solid.extrude`"), "{}", msg("Extrude"));
assert_eq!(msg("FusionFilletEdgesCommand"), "unknown command `FusionFilletEdgesCommand`");
+4
View File
@@ -57,6 +57,10 @@ impl Aabb2 {
pub fn contains(&self, p: Vec2) -> bool {
p.x >= self.min.x && p.y >= self.min.y && p.x <= self.max.x && p.y <= self.max.y
}
/// The box both hold: empty when they do not meet.
pub fn intersection(&self, o: &Aabb2) -> Aabb2 {
Aabb2 { min: Vec2::new(self.min.x.max(o.min.x), self.min.y.max(o.min.y)), max: Vec2::new(self.max.x.min(o.max.x), self.max.y.min(o.max.y)) }
}
/// Whether the two boxes share a point (never when either is empty).
pub fn meets(&self, o: &Aabb2) -> bool {
self.min.x <= o.max.x && o.min.x <= self.max.x && self.min.y <= o.max.y && o.min.y <= self.max.y
+254
View File
@@ -0,0 +1,254 @@
//! Cell content: what a cell shows, as text and fields, how it should fit, and the cell text rule
//! that places a label and a value in a cell. These are the rules every table of cells shares:
//! title blocks now, parts lists later. Plain data in sheet millimetres, like
//! [`crate::drawing_sheet`]: nothing here reads a field's value, measures text or draws.
//!
//! - [`Content`] is a list of [`Part`]s: text as written, or a field naming a value by its
//! [`Source`] and key, with a [`Format`]. Keys, units and decimals are kept as written; the
//! resolver checks them against each source. [`Content::validate`] bounds its size, and
//! [`check_label`] a label's.
//! - A value is never cut: when it has more than its [`Fit`] allows, it is shown whole with a
//! [`Warning`].
//!
//! The cell text rule (x to the right, y up), in the cell inside its lines, so the padding is
//! measured from a line's inner edge whatever the line's width:
//! - a label's baseline starts [`PADDING_MM`] in from the cell's left side, and its cap height
//! plus [`PADDING_MM`] down from the cell's top ([`label_at`]);
//! - the value box is the cell inset by [`PADDING_MM`], its top [`PADDING_MM`] below the label's
//! baseline when there is a label ([`value_box`]);
//! - one line of a value that doesn't wrap is centred top to bottom in the value box; otherwise the
//! lines run down from the box's top at the font's line pitch, the first baseline a cap height
//! below it ([`place_lines`]). Each baseline stays in the box, and its descenders
//! ([`DESCENDER_OF_CAP`]) may reach into the padding under it but never past it to a line;
//! - a line's anchor is on its baseline, at the box's left side, middle or right side: a writer
//! aligns the line on it.
//!
//! Breaking a value into lines that fit the box's width, and warning about width, need the text
//! measured: they come with the paper scene's text.
use serde::{Deserialize, Serialize};
use crate::paper::MAX_COORDINATE_MM;
use crate::{Aabb2, EPS, Vec2};
/// The most parts a cell's content may have.
pub const MAX_PARTS: usize = 64;
/// The most bytes of text a cell's content may hold in all, and a label.
pub const MAX_TEXT_BYTES: usize = 4096;
/// The space between the inner edge of a cell's lines and its text, in millimetres.
pub const PADDING_MM: f64 = 1.0;
/// How far a line's descenders reach below its baseline, as a share of its cap height: ISO 3098's
/// lettering B, whose lower-case tails are 3/10 of the capitals' height. They may reach into the
/// padding under a value box, never past it.
pub const DESCENDER_OF_CAP: f64 = 0.3;
/// Where a field's value comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Source {
/// The component the sheet documents.
Component,
/// The drawing.
Drawing,
/// The sheet the cell is on.
Sheet,
/// The drawing's newest revision.
Revision,
/// An answer given when the block was placed.
Prompt,
}
/// One part of a cell's content. Externally tagged: `{"text": "Sheet "}`,
/// `{"field": {"source": "sheet", "key": "number"}}`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Part {
/// Text shown as written.
Text(String),
/// The value `source` holds under `key` (such as `component` `mass`), shown by `format`.
Field {
source: Source,
key: String,
#[serde(default)]
format: Format,
},
}
/// How a field's number is shown; what isn't given comes from the caller's settings.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Format {
pub unit: Option<String>,
pub decimals: Option<u8>,
}
/// What a cell shows: its parts in order.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Content(pub Vec<Part>);
/// What a value should fit beyond its box.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Fit {
/// The most characters a value should have, line breaks not counted.
pub max_chars: Option<u32>,
/// The most for a value with a wide character (Chinese, Japanese, Korean or Yi), when it
/// differs from `max_chars`.
pub wide_max_chars: Option<u32>,
/// The value breaks between words to fit the box's width, its lines running down from the top.
#[serde(default)]
pub wrap: bool,
}
/// Where a line sits across its box.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HAlign {
#[default]
Left,
Centre,
Right,
}
/// The projection a drawing's views use, first or third angle (ISO 5456-2): the value of
/// `drawing.projection`, which the views and the title block's symbol both read.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Projection {
First,
Third,
}
/// What doesn't fit, when a value is shown whole.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Warning {
/// More characters than the fit allows.
Chars { count: usize, max: u32 },
/// More lines than the value box holds.
Height,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("cell: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
/// Checks text by the module's rule: at most [`MAX_TEXT_BYTES`] in all, and no control character
/// but a line break.
fn check_text<'a>(texts: impl IntoIterator<Item = &'a str>) -> Result<()> {
let mut bytes = 0usize;
for text in texts {
bytes = bytes.saturating_add(text.len());
require(bytes <= MAX_TEXT_BYTES, "a cell's text is at most 4096 bytes")?;
require(!text.chars().any(|c| c.is_control() && c != '\n'), "a cell's text has no control character but a line break")?;
}
Ok(())
}
impl Part {
/// Its text, or its key and unit.
fn texts(&self) -> impl Iterator<Item = &str> {
let (first, unit) = match self {
Part::Text(text) => (text.as_str(), None),
Part::Field { key, format, .. } => (key.as_str(), format.unit.as_deref()),
};
std::iter::once(first).chain(unit)
}
}
impl Content {
/// Checks the content's size: at most [`MAX_PARTS`] parts, counted first, and its text, keys
/// and units by the module's text rule.
pub fn validate(&self) -> Result<()> {
require(self.0.len() <= MAX_PARTS, "a cell has at most 64 parts")?;
check_text(self.0.iter().flat_map(Part::texts))
}
}
/// Checks a label's text: one line of at most [`MAX_TEXT_BYTES`], without control characters.
pub fn check_label(text: &str) -> Result<()> {
require(text.len() <= MAX_TEXT_BYTES && !text.chars().any(char::is_control), "a label is one line of at most 4096 bytes")
}
impl Fit {
/// [`Warning::Chars`] when `text` has more characters than the fit allows. A text with a wide
/// character is held to `wide_max_chars` when it is given.
pub fn warn_chars(&self, text: &str) -> Option<Warning> {
let wide = text.chars().any(is_wide);
let max = if wide { self.wide_max_chars.or(self.max_chars) } else { self.max_chars }?;
let count = text.chars().filter(|&c| c != '\n').count();
(count > max as usize).then_some(Warning::Chars { count, max })
}
}
/// The Unicode blocks of the scripts written in wide characters (Chinese, Japanese, Korean and Yi),
/// with their full-width and vertical forms.
const WIDE: [(u32, u32); 15] = [
(0x1100, 0x115F),
(0x2E80, 0x303E),
(0x3041, 0x33FF),
(0x3400, 0x4DBF),
(0x4E00, 0x9FFF),
(0xA000, 0xA4CF),
(0xA960, 0xA97F),
(0xAC00, 0xD7A3),
(0xF900, 0xFAFF),
(0xFE10, 0xFE19),
(0xFE30, 0xFE6F),
(0xFF00, 0xFF60),
(0xFFE0, 0xFFE6),
(0x20000, 0x2FFFD),
(0x30000, 0x3FFFD),
];
fn is_wide(c: char) -> bool {
let c = u32::from(c);
WIDE.iter().any(|&(first, last)| (first..=last).contains(&c))
}
/// Where a label `cap_mm` high goes in `cell` (the cell inside its lines): its baseline's left end.
pub fn label_at(cell: Aabb2, cap_mm: f64) -> Vec2 {
Vec2::new(cell.min.x + PADDING_MM, cell.max.y - PADDING_MM - cap_mm)
}
/// The box a cell's value goes in, under a label `label_cap_mm` high when it has one (`cell`: the
/// cell inside its lines).
pub fn value_box(cell: Aabb2, label_cap_mm: Option<f64>) -> Aabb2 {
let inset = cell.inflated(-PADDING_MM);
match label_cap_mm {
Some(cap_mm) => Aabb2 { max: Vec2::new(inset.max.x, label_at(cell, cap_mm).y - PADDING_MM), ..inset },
None => inset,
}
}
/// Where `lines` lines `cap_mm` high go in `value_box`, `pitch_mm` apart, by the module's rule:
/// the first line's anchor, each next line `pitch_mm` below the one before, and
/// [`Warning::Height`] when the last baseline is below the box or its descenders below the padding
/// under it. The box lies within the paper's coordinates ([`MAX_COORDINATE_MM`]), so the anchor is
/// always finite.
pub fn place_lines(value_box: Aabb2, lines: usize, cap_mm: f64, pitch_mm: f64, align: HAlign, wrap: bool) -> Result<(Vec2, Option<Warning>)> {
let Aabb2 { min, max } = value_box;
let on_paper = [min.x, min.y, max.x, max.y].into_iter().all(|v| v.abs() <= MAX_COORDINATE_MM);
require(on_paper && min.x <= max.x && min.y <= max.y, "a value box is not inside out and lies within the paper's coordinates")?;
require([cap_mm, pitch_mm].into_iter().all(|v| v.is_finite() && v > 0.0), "a cap height and a line pitch are finite and above 0")?;
let x = match align {
HAlign::Left => min.x,
HAlign::Centre => value_box.center().x,
HAlign::Right => max.x,
};
let y = if lines == 1 && !wrap { value_box.center().y - cap_mm / 2.0 } else { max.y - cap_mm };
let last = y - lines.saturating_sub(1) as f64 * pitch_mm;
// How far above the box's bottom the last baseline must be for its descenders to stay in the padding.
let reach = (cap_mm * DESCENDER_OF_CAP - PADDING_MM).max(0.0);
let overflows = lines > 0 && last - reach < min.y - EPS;
Ok((Vec2::new(x, y), overflows.then_some(Warning::Height)))
}
#[cfg(test)]
#[path = "cell_tests.rs"]
mod tests;
+345
View File
@@ -0,0 +1,345 @@
use serde_json::json;
use super::*;
use crate::bspline::tests::Lcg;
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Aabb2 {
Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }
}
fn text(s: &str) -> Part {
Part::Text(s.to_string())
}
fn field(source: Source, key: &str) -> Part {
Part::Field { source, key: key.to_string(), format: Format::default() }
}
/// The title cell's value box on an A3 sheet: the cell (230, 10)-(342, 24) under a 2.5 mm label.
const TITLE_BOX: Aabb2 = Aabb2 { min: Vec2::new(231.0, 11.0), max: Vec2::new(341.0, 19.5) };
#[test]
fn the_label_and_the_value_box_inset_the_cell_by_the_padding() {
let cell = rect(230.0, 10.0, 342.0, 24.0);
// The label's baseline 1 + 2.5 mm below the top; the value box's top 1 mm below that.
assert_eq!(label_at(cell, 2.5), Vec2::new(231.0, 20.5));
assert_eq!(value_box(cell, Some(2.5)), TITLE_BOX);
assert_eq!(value_box(cell, None), rect(231.0, 11.0, 341.0, 23.0));
}
#[test]
fn one_line_sits_centred_in_its_value_box_at_the_left_middle_or_right() {
// The box's centre is 15.25 high: the 5 mm line's baseline 2.5 below it.
for (align, x) in [(HAlign::Left, 231.0), (HAlign::Centre, 286.0), (HAlign::Right, 341.0)] {
assert_eq!(place_lines(TITLE_BOX, 1, 5.0, 8.0, align, false), Ok((Vec2::new(x, 12.75), None)), "{align:?}");
}
}
#[test]
fn wrapped_lines_and_several_lines_run_down_from_the_top_at_the_pitch() {
let square = rect(0.0, 0.0, 10.0, 10.0);
// A fake font's pitch of 1.6 cap heights: at 2.5 mm the baselines are 2.5 and 6.5 below the top.
let (first, warning) = place_lines(square, 2, 2.5, 4.0, HAlign::Left, true).unwrap();
assert_eq!((first, warning), (Vec2::new(0.0, 7.5), None));
assert_eq!(first.y - 4.0, 10.0 - 6.5);
// Two lines without wrapping start at the top too; one line that wraps isn't centred.
assert_eq!(place_lines(square, 2, 2.5, 4.0, HAlign::Left, false), Ok((Vec2::new(0.0, 7.5), None)));
assert_eq!(place_lines(square, 1, 2.5, 4.0, HAlign::Right, true), Ok((Vec2::new(10.0, 7.5), None)));
assert_eq!(place_lines(square, 1, 2.5, 4.0, HAlign::Right, false), Ok((Vec2::new(10.0, 3.75), None)));
}
#[test]
fn lines_below_the_box_or_with_descenders_past_its_padding_warn_height() {
// 14.5 mm high: four lines at 2.5 and a pitch of 4 end exactly on the bottom; a fifth doesn't fit.
let tall = rect(311.0, 45.0, 379.0, 59.5);
for wrap in [false, true] {
assert_eq!(place_lines(tall, 4, 2.5, 4.0, HAlign::Left, wrap), Ok((Vec2::new(311.0, 57.0), None)));
assert_eq!(place_lines(tall, 5, 2.5, 4.0, HAlign::Left, wrap), Ok((Vec2::new(311.0, 57.0), Some(Warning::Height))));
}
// Within 1e-9 mm of the bottom is on it.
let raised = |by: f64| Aabb2 { min: Vec2::new(311.0, 45.0 + by), ..tall };
assert_eq!(place_lines(raised(0.5e-9), 4, 2.5, 4.0, HAlign::Left, true).unwrap().1, None);
assert_eq!(place_lines(raised(2e-9), 4, 2.5, 4.0, HAlign::Left, true).unwrap().1, Some(Warning::Height));
// One centred line fits while its cap height does and its descenders stay in the 1 mm padding:
// a 2.5 mm line's 0.75 mm do, so it fits a 2.5 mm box (not 2.6 mm of line). A 5 mm line's
// 1.5 mm reach 0.5 mm past the padding from a baseline on the box's bottom, so it needs 6 mm.
let low = |height: f64| rect(0.0, 0.0, 10.0, height);
assert_eq!(place_lines(low(2.5), 1, 2.5, 4.0, HAlign::Left, false), Ok((Vec2::new(0.0, 0.0), None)));
assert_eq!(place_lines(low(2.5), 1, 2.6, 4.0, HAlign::Left, false).unwrap().1, Some(Warning::Height));
assert_eq!(place_lines(low(6.0), 1, 5.0, 8.0, HAlign::Left, false), Ok((Vec2::new(0.0, 0.5), None)));
for height in [5.0, 5.9] {
assert_eq!(place_lines(low(height), 1, 5.0, 8.0, HAlign::Left, false).unwrap().1, Some(Warning::Height), "{height}");
}
// The same for the last of several: two 5 mm lines 8 mm apart need 13.5 mm, not 13.4.
assert_eq!(place_lines(low(13.5), 2, 5.0, 8.0, HAlign::Left, true), Ok((Vec2::new(0.0, 8.5), None)));
assert_eq!(place_lines(low(13.4), 2, 5.0, 8.0, HAlign::Left, true).unwrap().1, Some(Warning::Height));
// A flat box holds no line; no lines never overflow; a vast count still answers.
assert_eq!(place_lines(rect(0.0, 1.0, 10.0, 1.0), 1, 1.0, 1.6, HAlign::Left, false).unwrap().1, Some(Warning::Height));
assert_eq!(place_lines(rect(0.0, 1.0, 10.0, 1.0), 0, 1.0, 1.6, HAlign::Left, false).unwrap().1, None);
assert_eq!(place_lines(tall, usize::MAX, 2.5, 4.0, HAlign::Left, true).unwrap().1, Some(Warning::Height));
}
#[test]
fn a_hostile_value_box_cap_or_pitch_is_refused() {
const BOX: Invalid = Invalid("a value box is not inside out and lies within the paper's coordinates");
const SIZES: Invalid = Invalid("a cap height and a line pitch are finite and above 0");
for value_box in [
Aabb2::EMPTY,
rect(10.0, 0.0, 9.0, 5.0),
rect(0.0, 5.0, 10.0, 4.999),
rect(f64::NAN, 0.0, 10.0, 5.0),
rect(0.0, 0.0, f64::INFINITY, 5.0),
rect(0.0, f64::NEG_INFINITY, 10.0, 5.0),
// Finite and in order, but past the paper's coordinates: centred, they would overflow.
rect(1e308, 0.0, 1.1e308, 10.0),
rect(0.0, 1e308, 10.0, 1.1e308),
rect(0.0, 0.0, 100_000.001, 5.0),
rect(-100_000.001, 0.0, 10.0, 5.0),
] {
assert_eq!(place_lines(value_box, 1, 2.5, 4.0, HAlign::Left, false), Err(BOX), "{value_box:?}");
}
for v in [0.0, -0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(place_lines(TITLE_BOX, 1, v, 4.0, HAlign::Left, false), Err(SIZES), "cap {v}");
assert_eq!(place_lines(TITLE_BOX, 1, 2.5, v, HAlign::Left, false), Err(SIZES), "pitch {v}");
}
place_lines(TITLE_BOX, 1, f64::MIN_POSITIVE, f64::MIN_POSITIVE, HAlign::Left, false).unwrap();
let paper = rect(-MAX_COORDINATE_MM, -MAX_COORDINATE_MM, MAX_COORDINATE_MM, MAX_COORDINATE_MM);
for align in [HAlign::Left, HAlign::Centre, HAlign::Right] {
assert!(place_lines(paper, 1, 2.5, 4.0, align, false).unwrap().0.is_finite(), "{align:?}");
}
}
#[test]
fn a_count_warns_above_its_maximum_in_characters_not_bytes() {
let title = Fit { max_chars: Some(25), wide_max_chars: Some(30), wrap: false };
let chars = |count, max| Some(Warning::Chars { count, max });
let repeat = |c: &str, n: usize| c.repeat(n);
assert_eq!(title.warn_chars(&repeat("0", 25)), None);
assert_eq!(title.warn_chars(&repeat("0", 26)), chars(26, 25));
assert_eq!(title.warn_chars(&repeat("0", 40)), chars(40, 25));
// 'é' is two bytes and one character.
assert_eq!(title.warn_chars(&repeat("é", 25)), None);
assert_eq!(title.warn_chars(&repeat("é", 40)), chars(40, 25));
// A text with a wide character is held to the wide count, whatever else it holds.
assert_eq!(title.warn_chars(&repeat("字", 30)), None);
assert_eq!(title.warn_chars(&repeat("字", 31)), chars(31, 30));
assert_eq!(title.warn_chars(&format!("A{}", repeat("字", 29))), None);
assert_eq!(title.warn_chars(&format!("{}字", repeat("A", 30))), chars(31, 30));
// Line breaks don't count.
assert_eq!(title.warn_chars(&format!("{}\n", repeat("0", 25))), None);
assert_eq!(title.warn_chars(""), None);
// No wide count: the count holds for every text. No count: nothing warns.
let plain = Fit { max_chars: Some(25), ..Fit::default() };
assert_eq!(plain.warn_chars(&repeat("字", 26)), chars(26, 25));
let wide_only = Fit { wide_max_chars: Some(30), ..Fit::default() };
assert_eq!(wide_only.warn_chars(&repeat("0", 1000)), None);
assert_eq!(wide_only.warn_chars(&repeat("字", 31)), chars(31, 30));
assert_eq!(Fit::default().warn_chars(&repeat("字", 1000)), None);
let none = Fit { max_chars: Some(0), ..Fit::default() };
assert_eq!(none.warn_chars("a"), chars(1, 0));
assert_eq!(none.warn_chars("\n"), None);
}
#[test]
fn wide_characters_are_those_of_the_wide_scripts_blocks() {
for c in ['字', '한', 'カ', 'ひ', 'A', '。', 'ꀀ', '\u{1100}', '\u{115F}', '\u{303E}', '\u{20000}', '\u{3FFFD}', '\u{FFE6}'] {
assert!(is_wide(c), "{c:?} U+{:04X}", u32::from(c));
}
for c in ['A', 'é', 'Ω', 'Я', 'א', '\u{1160}', '\u{303F}', '\u{3040}', '\u{FF61}', '\u{FFE7}', '\u{3FFFE}', '😀', '\n'] {
assert!(!is_wide(c), "{c:?} U+{:04X}", u32::from(c));
}
}
#[test]
fn content_has_at_most_64_parts_and_4096_bytes_of_text() {
Content(vec![text("a"); MAX_PARTS]).validate().unwrap();
assert_eq!(Content(vec![text(""); MAX_PARTS + 1]).validate(), Err(Invalid("a cell has at most 64 parts")));
const LONG: Invalid = Invalid("a cell's text is at most 4096 bytes");
Content(vec![text(&"a".repeat(4096))]).validate().unwrap();
assert_eq!(Content(vec![text(&"a".repeat(4097))]).validate(), Err(LONG));
// Bytes, not characters: 2048 'é' are 4096 bytes.
Content(vec![text(&"é".repeat(2048))]).validate().unwrap();
assert_eq!(Content(vec![text(&"é".repeat(2048)), text("a")]).validate(), Err(LONG));
// Keys and units count too.
let unit =
|unit: &str| Part::Field { source: Source::Component, key: "k".repeat(2047), format: Format { unit: Some(unit.into()), decimals: None } };
Content(vec![text(&"a".repeat(2048)), unit("g")]).validate().unwrap();
assert_eq!(Content(vec![text(&"a".repeat(2048)), unit("kg")]).validate(), Err(LONG));
assert_eq!(Content(vec![text(&"a".repeat(2048)), field(Source::Drawing, &"k".repeat(2049))]).validate(), Err(LONG));
// Counted first: 65 parts of bad text report the count.
assert_eq!(Content(vec![text("\t"); MAX_PARTS + 1]).validate(), Err(Invalid("a cell has at most 64 parts")));
Content::default().validate().unwrap();
}
#[test]
fn content_has_no_control_character_but_a_line_break() {
const CONTROL: Invalid = Invalid("a cell's text has no control character but a line break");
Content(vec![text("Anodised\nblack"), text("\n")]).validate().unwrap();
for c in ['\t', '\r', '\0', '\u{1b}', '\u{7f}', '\u{85}', '\u{9f}'] {
let s = format!("a{c}b");
assert_eq!(Content(vec![text(&s)]).validate(), Err(CONTROL), "{c:?}");
assert_eq!(Content(vec![field(Source::Sheet, &s)]).validate(), Err(CONTROL), "{c:?}");
let format = Format { unit: Some(s), decimals: Some(1) };
assert_eq!(Content(vec![Part::Field { source: Source::Component, key: "mass".into(), format }]).validate(), Err(CONTROL), "{c:?}");
}
}
#[test]
fn parts_read_and_write_as_externally_tagged_json() {
let content = Content(vec![
text("Sheet "),
field(Source::Sheet, "number"),
Part::Field { source: Source::Component, key: "mass".into(), format: Format { unit: Some("g".into()), decimals: Some(1) } },
]);
let value = serde_json::to_value(&content).unwrap();
assert_eq!(
value,
json!([
{"text": "Sheet "},
{"field": {"source": "sheet", "key": "number", "format": {"unit": null, "decimals": null}}},
{"field": {"source": "component", "key": "mass", "format": {"unit": "g", "decimals": 1}}},
])
);
assert_eq!(serde_json::from_value::<Content>(value).unwrap(), content);
// Without a format, the caller's settings.
let read = |v: serde_json::Value| serde_json::from_value::<Content>(json!([v]));
assert_eq!(read(json!({"field": {"source": "revision", "key": "date"}})).unwrap(), Content(vec![field(Source::Revision, "date")]));
for (name, source) in [
("component", Source::Component),
("drawing", Source::Drawing),
("sheet", Source::Sheet),
("revision", Source::Revision),
("prompt", Source::Prompt),
] {
assert_eq!(serde_json::to_value(source).unwrap(), json!(name));
}
for bad in [
json!({"field": {"source": "customer", "key": "name"}}),
json!({"field": {"source": "Component", "key": "name"}}),
json!({"field": {"source": "sheet", "key": "number", "scale": 1}}),
json!({"field": {"source": "sheet", "key": "number", "format": {"date": "ymd"}}}),
json!({"field": {"source": "component", "key": "mass", "format": {"decimals": 256}}}),
json!({"field": {"source": "component", "key": "mass", "format": {"decimals": -1}}}),
json!({"field": {"source": "component", "key": "mass", "format": {"decimals": 1.5}}}),
json!({"field": {"source": "sheet"}}),
json!({"field": {"source": "sheet", "key": 1}}),
json!({"expr": "a + 1"}),
json!({"text": "a", "field": {"source": "sheet", "key": "number"}}),
json!({"kind": "text", "text": "a"}),
json!("Sheet"),
] {
assert!(read(bad.clone()).is_err(), "{bad}");
}
}
#[test]
fn a_label_is_one_line_of_at_most_4096_bytes() {
const LABEL: Invalid = Invalid("a label is one line of at most 4096 bytes");
for ok in ["Created by", "", "Masse (kg)", &"é".repeat(2048)] {
check_label(ok).unwrap();
}
for bad in ["Title\nblock", "\t", "a\u{85}", &"a".repeat(4097), &"é".repeat(2049)] {
assert_eq!(check_label(bad), Err(LABEL), "{bad:?}");
}
}
#[test]
fn alignments_projections_and_fits_have_snake_case_names() {
for (align, name) in [(HAlign::Left, "left"), (HAlign::Centre, "centre"), (HAlign::Right, "right")] {
assert_eq!(serde_json::to_value(align).unwrap(), json!(name));
assert_eq!(serde_json::from_value::<HAlign>(json!(name)).unwrap(), align);
}
assert_eq!(HAlign::default(), HAlign::Left);
for (projection, name) in [(Projection::First, "first"), (Projection::Third, "third")] {
assert_eq!(serde_json::to_value(projection).unwrap(), json!(name));
assert_eq!(serde_json::from_value::<Projection>(json!(name)).unwrap(), projection);
}
for bad in ["center", "Left", "middle", ""] {
assert!(serde_json::from_value::<HAlign>(json!(bad)).is_err(), "{bad}");
}
for bad in ["First", "first_angle", "second", ""] {
assert!(serde_json::from_value::<Projection>(json!(bad)).is_err(), "{bad}");
}
let fit = Fit { max_chars: Some(25), wide_max_chars: Some(30), wrap: true };
let value = serde_json::to_value(fit).unwrap();
assert_eq!(value, json!({"max_chars": 25, "wide_max_chars": 30, "wrap": true}));
assert_eq!(serde_json::from_value::<Fit>(value).unwrap(), fit);
assert_eq!(serde_json::from_value::<Fit>(json!({})).unwrap(), Fit::default());
assert!(serde_json::from_value::<Fit>(json!({"padding_mm": 1.0})).is_err());
assert!(serde_json::from_value::<Fit>(json!({"max_chars": -1})).is_err());
}
/// A whole number below `n` from `rng`.
fn pick(rng: &mut Lcg, n: usize) -> usize {
((rng.unit() * n as f64) as usize).min(n - 1)
}
/// Random content, texts and boxes, hostile ones among them: validation agrees with the rule worked
/// out independently, the count agrees with one by hand, and nothing panics.
#[test]
fn fuzzed_content_texts_and_boxes_never_panic_and_follow_the_rules() {
const CHARS: [char; 14] = ['a', 'Z', '0', ' ', '\n', '\t', '\r', '\u{7f}', '\u{85}', 'é', '字', '한', '😀', '\0'];
const NUMBERS: [f64; 13] = [0.0, -1.0, 1e-300, 2.5, 5.0, 14.5, 1e5, 1.1e5, -1e308, 1e308, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
let string = |rng: &mut Lcg, len: usize| -> String { (0..len).map(|_| CHARS[pick(rng, CHARS.len())]).collect() };
let number = |rng: &mut Lcg| if pick(rng, 4) == 0 { NUMBERS[pick(rng, NUMBERS.len())] } else { rng.unit() * 200.0 - 50.0 };
let mut rng = Lcg(0x5eed);
let mut accepted = 0;
let mut combinations = std::collections::BTreeSet::new();
for round in 0..3000 {
let lengths = [0, 1, 7, 60, 1100, 4097];
let mut parts = Vec::new();
for i in 0..[0, 1, 3, 64, 65][round % 5] {
let len = lengths[(round + i) % lengths.len()];
parts.push(match i % 3 {
0 => Part::Text(string(&mut rng, len)),
1 => Part::Field { source: Source::Drawing, key: string(&mut rng, len % 40), format: Format::default() },
_ => {
let format = Format { unit: Some(string(&mut rng, len % 5)), decimals: Some(3) };
Part::Field { source: Source::Component, key: "mass".into(), format }
}
});
}
let content = Content(parts);
let strings: Vec<&str> = content
.0
.iter()
.flat_map(|p| match p {
Part::Text(t) => vec![t.as_str()],
Part::Field { key, format, .. } => [Some(key.as_str()), format.unit.as_deref()].into_iter().flatten().collect(),
})
.collect();
let bytes: usize = strings.iter().map(|s| s.len()).sum();
let clean = strings.iter().all(|s| s.chars().all(|c| c == '\n' || !c.is_control()));
let valid = content.0.len() <= MAX_PARTS && bytes <= MAX_TEXT_BYTES && clean;
assert_eq!(content.validate().is_ok(), valid, "round {round}: {} parts, {bytes} bytes, clean {clean}", content.0.len());
accepted += usize::from(valid);
let back: Content = serde_json::from_value(serde_json::to_value(&content).unwrap()).unwrap();
assert_eq!(back, content);
let fit = Fit { max_chars: Some(30), wide_max_chars: Some(20), wrap: false };
for s in &strings {
let count = s.chars().filter(|&c| c != '\n').count();
let max = if s.chars().any(is_wide) { 20 } else { 30 };
assert_eq!(fit.warn_chars(s), (count > max as usize).then_some(Warning::Chars { count, max }), "{s:?}");
}
let (x0, y0, x1, y1, cap, pitch) =
(number(&mut rng), number(&mut rng), number(&mut rng), number(&mut rng), number(&mut rng), number(&mut rng));
// Each of the lines, the alignment and the wrapping picked on its own, so every combination comes up.
let lines = [0, 1, 2, 5, 1000, usize::MAX][pick(&mut rng, 6)];
let (align, wrap) = ([HAlign::Left, HAlign::Centre, HAlign::Right][pick(&mut rng, 3)], pick(&mut rng, 2) == 0);
combinations.insert((lines, align as u8, wrap));
let value_box = rect(x0, y0, x1, y1);
let on_paper = [x0, y0, x1, y1].iter().all(|v| v.abs() <= MAX_COORDINATE_MM);
let sane = on_paper && [cap, pitch].iter().all(|v| v.is_finite() && *v > 0.0) && x0 <= x1 && y0 <= y1;
let placed = place_lines(value_box, lines, cap, pitch, align, wrap);
assert_eq!(placed.is_ok(), sane, "{value_box:?} {cap} {pitch}");
if let Ok((at, warning)) = placed {
assert!(at.is_finite() && at.x >= x0 && at.x <= x1 && at.y <= y1, "{at:?} in {value_box:?}");
if lines == 1 && warning.is_none() {
// Its baseline in the box, and its descenders above the line's inner edge, the padding below it.
assert!(at.y >= y0 - EPS && at.y - cap * DESCENDER_OF_CAP >= y0 - PADDING_MM - EPS, "{at:?} {cap} in {value_box:?}");
}
}
}
assert!(accepted > 500, "{accepted}");
assert_eq!(combinations.len(), 6 * 3 * 2, "every lines, alignment and wrapping together");
}
+521
View File
@@ -0,0 +1,521 @@
//! Drawing sheets: paper sizes, orientation and the drawing border, as data in sheet millimetres.
//!
//! A [`Sheet`] is a paper size and an orientation; [`Sheet::size_mm`] gives its size as it lies, the
//! sheet border running round it. A [`DrawingBorderProfile`] is the rule for the drawing border
//! inside the sheet border: the margins between the two, the drawing border's line width, the grid
//! of reference fields with their labels, and the centring and trimming marks.
//! [`DrawingBorderProfile::validate`] checks a profile on its own, as when one is read, and
//! [`DrawingBorderProfile::layout`] works it out for one sheet, so a new size regrows it. Nothing
//! here is frozen geometry, saved in a design or drawn: a writer draws the [`SheetLayout`] later.
//!
//! Sheet coordinates are millimetres from the sheet's lower-left corner, x to the right and y up.
//! The drawing border's line is centred on [`SheetLayout::drawing_border`].
//!
//! [`DrawingBorderProfile::iso`] is the built-in profile, after ISO 5457: 10 mm margins all round,
//! a 0.7 mm drawing border, and 50 mm fields counted out from the sheet's centre lines, the corner
//! fields taking what is left. Rows are lettered from the top without I and O and columns numbered
//! from the left, on the sides [`LabelSides::Iso`] gives: the top and right of an A4 sheet, every
//! side of the others. The labels sit between the drawing border and the grid reference border,
//! 5 mm outside it. A centring mark runs across the whole margin, from the trimmed sheet's edge to
//! the drawing border, at each end of the sheet's centre lines, never into the drawing area.
//!
//! The rest of ISO 5457's border is a choice in the profile: the 20 mm filing margin on the left,
//! for drawings that get bound; centring marks that start at the grid reference border and reach
//! inside the drawing border (10 mm in the standard's figure); and a trimming mark at each corner, outside the grid reference border, of
//! two overlapping 10 x 5 mm rectangles. The built-in profile leaves the trimming marks out, as
//! they take a lot of ink.
//!
//! Sizes: ISO 216 A0 to A5, Letter and Legal, ANSI A to F (ASME Y14.1), the architectural A to E
//! and E1, and custom sizes, finite, above 0 and at most [`MAX_SIDE_MM`] a side. Title blocks sit on
//! the drawing border ([`crate::title_block`]). The elongated ISO sizes and the extra centring marks a
//! sheet larger than A0 needs (one in the middle of each section to be filmed) are not here yet.
use serde::{Deserialize, Serialize};
use crate::paper::MAX_LINE_MM;
/// The longest side a custom sheet may have, in millimetres: the paper scene's limit.
pub use crate::paper::MAX_SIDE_MM;
use crate::{Aabb2, Vec2};
/// The most fields an equal division may cut each way.
pub const MAX_EQUAL_FIELDS: u32 = 200;
/// ISO 5457's filing margin: the left margin of a drawing that gets bound.
pub const FILING_MARGIN_MM: f64 = 20.0;
const MIN_FIELD_MM: f64 = 1.0;
const MAX_FIELD_MM: f64 = 1000.0;
/// The smallest drawing space the margins may leave, each way.
const MIN_DRAWING_SPACE_MM: f64 = 1.0;
/// A corner field shorter than this joins its neighbour, so rounding never leaves a sliver.
const SLIVER_MM: f64 = 1e-9;
const INCH: f64 = 25.4;
/// A standard paper size.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaperSize {
A0,
A1,
A2,
A3,
A4,
A5,
Letter,
Legal,
AnsiA,
AnsiB,
AnsiC,
AnsiD,
AnsiE,
AnsiF,
ArchA,
ArchB,
ArchC,
ArchD,
ArchE,
ArchE1,
}
impl PaperSize {
pub const ALL: [PaperSize; 20] = {
use PaperSize::*;
[A0, A1, A2, A3, A4, A5, Letter, Legal, AnsiA, AnsiB, AnsiC, AnsiD, AnsiE, AnsiF, ArchA, ArchB, ArchC, ArchD, ArchE, ArchE1]
};
/// The short and the long side, in millimetres (the US sizes from their inches).
pub fn sides_mm(self) -> (f64, f64) {
use PaperSize::*;
let inches = |short: f64, long: f64| (short * INCH, long * INCH);
match self {
A0 => (841.0, 1189.0),
A1 => (594.0, 841.0),
A2 => (420.0, 594.0),
A3 => (297.0, 420.0),
A4 => (210.0, 297.0),
A5 => (148.0, 210.0),
Letter | AnsiA => inches(8.5, 11.0),
Legal => inches(8.5, 14.0),
AnsiB => inches(11.0, 17.0),
AnsiC => inches(17.0, 22.0),
AnsiD => inches(22.0, 34.0),
AnsiE => inches(34.0, 44.0),
AnsiF => inches(28.0, 40.0),
ArchA => inches(9.0, 12.0),
ArchB => inches(12.0, 18.0),
ArchC => inches(18.0, 24.0),
ArchD => inches(24.0, 36.0),
ArchE => inches(36.0, 48.0),
ArchE1 => inches(30.0, 42.0),
}
}
}
/// A sheet's size: a standard one or any other.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum SheetSize {
Standard(PaperSize),
/// The two sides in either order; the orientation decides which lies across.
Custom {
sides_mm: [f64; 2],
},
}
/// Which way the sheet lies. Landscape puts the long side across.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Orientation {
#[default]
Landscape,
Portrait,
}
/// One sheet: its size and the way it lies (landscape when not given).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Sheet {
pub size: SheetSize,
#[serde(default)]
pub orientation: Orientation,
}
/// The distances from the sheet border to the drawing border, in millimetres.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Margins {
pub left_mm: f64,
pub right_mm: f64,
pub top_mm: f64,
pub bottom_mm: f64,
}
impl Margins {
/// Left, right, top and bottom.
fn sides(self) -> [f64; 4] {
[self.left_mm, self.right_mm, self.top_mm, self.bottom_mm]
}
}
/// How the drawing border is cut into reference fields. Externally tagged (`"none"`,
/// `{"from_centre": {...}}`), as serde ignores extra fields on an internally tagged unit variant.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum GridRule {
/// No reference fields.
None,
/// Fields of `field_mm` counted out from the sheet's centre lines, the corner fields taking
/// what is left (ISO 5457, with 50 mm). Each way the larger half sets the count `n` (its
/// length over `field_mm`, rounded, at least 1), and each half has `n` fields, or fewer where
/// it is shorter.
FromCentre { field_mm: f64 },
/// The drawing border cut into equal fields.
Equal { columns: u32, rows: u32 },
}
/// Sides of the drawing border.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Sides {
pub top: bool,
pub bottom: bool,
pub left: bool,
pub right: bool,
}
impl Sides {
pub const ALL: Sides = Sides { top: true, bottom: true, left: true, right: true };
}
/// Which sides of the drawing border carry the field labels: a rule for any sheet, so a profile
/// made for one size labels another correctly.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum LabelSides {
/// ISO 5457's: the top and right of an A4 sheet (either way), every side of the others.
Iso,
/// The same sides on every sheet.
Fixed(Sides),
}
impl LabelSides {
/// The sides labelled on `sheet`.
pub fn on(self, sheet: &Sheet) -> Sides {
match self {
LabelSides::Iso if matches!(sheet.size, SheetSize::Standard(PaperSize::A4)) => {
Sides { top: true, bottom: false, left: false, right: true }
}
LabelSides::Iso => Sides::ALL,
LabelSides::Fixed(sides) => sides,
}
}
}
/// The rule for a drawing border and the marks around it: worked out for each sheet by
/// [`DrawingBorderProfile::layout`].
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DrawingBorderProfile {
pub margins: Margins,
/// ISO 5457's filing margin: when on, the left margin is [`FILING_MARGIN_MM`] in place of
/// `margins.left_mm`, which is kept for when it's off again. Off when not given.
#[serde(default)]
pub filing_margin: bool,
/// The drawing border's line width; the centring marks are drawn in it too.
pub drawing_border_line_mm: f64,
pub grid: GridRule,
/// Row letters skip I and O (they read as 1 and 0).
pub skip_i_and_o: bool,
pub label_sides: LabelSides,
/// How far outside the drawing border the grid reference border runs; the labels sit between
/// the two.
pub grid_border_mm: f64,
/// How far each centring mark reaches inside the drawing border, 0 to stop it on the drawing
/// border, at most [`MAX_SIDE_MM`] (`None`: no centring marks).
pub centring_mark_mm: Option<f64>,
/// Where each centring mark starts outside the drawing border. The trimmed sheet's edge when not
/// given.
#[serde(default)]
pub centring_mark_start: CentringMarkStart,
/// The trimming mark's rectangles: their length along the sheet border, at most
/// [`MAX_SIDE_MM`], and their width into the sheet, which must leave them outside the grid
/// reference border (`None`: no trimming marks).
pub trimming_mark_mm: Option<[f64; 2]>,
}
/// Where a centring mark starts, outside the drawing border.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CentringMarkStart {
/// The trimmed sheet's edge, so the mark runs across the whole margin (the built-in sheet's).
#[default]
SheetEdge,
/// The grid reference border, as ISO 5457's figure 5 draws it.
GridBorder,
}
/// One reference field along its axis: `from_mm < to_mm` in sheet coordinates.
#[derive(Clone, Debug, PartialEq)]
pub struct Field {
pub from_mm: f64,
pub to_mm: f64,
pub label: String,
}
/// A sheet's drawing border and the marks around it, worked out: what a writer draws.
#[derive(Clone, Debug, PartialEq)]
pub struct SheetLayout {
pub sheet_border: Aabb2,
pub drawing_border: Aabb2,
pub drawing_border_line_mm: f64,
/// Left to right, numbered from 1.
pub columns: Vec<Field>,
/// Top to bottom, lettered from A.
pub rows: Vec<Field>,
/// The sides labelled on this sheet.
pub label_sides: Sides,
pub grid_border: Aabb2,
/// Bottom, top, left and right: each from the grid reference border to the drawing border, or
/// as far inside it as the profile asks.
pub centring_marks: Vec<[Vec2; 2]>,
/// Filled rectangles, two at each corner of the sheet.
pub trimming_marks: Vec<Aabb2>,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("drawing sheet: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
impl Sheet {
pub fn standard(size: PaperSize, orientation: Orientation) -> Sheet {
Sheet { size: SheetSize::Standard(size), orientation }
}
/// The sheet's width and height as it lies.
pub fn size_mm(&self) -> Result<Vec2> {
let (short, long) = match self.size {
SheetSize::Standard(size) => size.sides_mm(),
SheetSize::Custom { sides_mm: [a, b] } => {
for side in [a, b] {
require(side.is_finite() && side > 0.0 && side <= MAX_SIDE_MM, "a custom side is finite, above 0 and at most 5000 mm")?;
}
(a.min(b), a.max(b))
}
};
Ok(match self.orientation {
Orientation::Landscape => Vec2::new(long, short),
Orientation::Portrait => Vec2::new(short, long),
})
}
}
impl DrawingBorderProfile {
/// The built-in drawing border, after ISO 5457, for a sheet of any size (the module's notes
/// say how it follows the standard).
pub fn iso() -> DrawingBorderProfile {
DrawingBorderProfile {
margins: Margins { left_mm: 10.0, right_mm: 10.0, top_mm: 10.0, bottom_mm: 10.0 },
filing_margin: false,
drawing_border_line_mm: 0.7,
grid: GridRule::FromCentre { field_mm: 50.0 },
skip_i_and_o: true,
label_sides: LabelSides::Iso,
grid_border_mm: 5.0,
centring_mark_mm: Some(0.0),
centring_mark_start: CentringMarkStart::SheetEdge,
trimming_mark_mm: None,
}
}
/// The margins as laid out: `margins`, with the filing margin on the left when it's on.
fn laid_out_margins(&self) -> Margins {
let left_mm = if self.filing_margin { FILING_MARGIN_MM } else { self.margins.left_mm };
Margins { left_mm, ..self.margins }
}
/// Checks what doesn't depend on the sheet: call it when a profile is read. [`Self::layout`]
/// calls it too.
pub fn validate(&self) -> Result<()> {
// Every margin as given, the left one too while the filing margin stands in for it, so
// turning the filing margin off never brings back a bad value.
for margin in self.margins.sides() {
require(margin.is_finite() && (0.0..=MAX_SIDE_MM).contains(&margin), "a margin is finite and 0 to 5000 mm")?;
}
let line = self.drawing_border_line_mm;
require(line.is_finite() && line > 0.0 && line <= MAX_LINE_MM, "the drawing border's line is above 0 and at most 10 mm wide")?;
match self.grid {
GridRule::None => {}
GridRule::FromCentre { field_mm } => {
require(field_mm.is_finite() && (MIN_FIELD_MM..=MAX_FIELD_MM).contains(&field_mm), "a field is 1 to 1000 mm long")?
}
GridRule::Equal { columns, rows } => {
let counts = 1..=MAX_EQUAL_FIELDS;
require(counts.contains(&columns) && counts.contains(&rows), "an equal division has 1 to 200 fields each way")?
}
}
// The grid reference border lies in the margins, and the trimming marks between it and the
// sheet border (ISO 5457 4.5), so the narrowest margin laid out bounds both.
let narrowest = self.laid_out_margins().sides().into_iter().fold(f64::INFINITY, f64::min);
let g = self.grid_border_mm;
require(g.is_finite() && g >= 0.0 && g <= narrowest, "the grid reference border lies between the sheet border and the drawing border")?;
if let Some(reach) = self.centring_mark_mm {
let fits = reach.is_finite() && (0.0..=MAX_SIDE_MM).contains(&reach);
require(fits, "a centring mark ends on the drawing border or at most 5000 mm inside it")?;
// Else it would start and end on the drawing border.
let outside = match self.centring_mark_start {
CentringMarkStart::SheetEdge => narrowest,
CentringMarkStart::GridBorder => g,
};
require(outside > 0.0 || reach > 0.0, "a centring mark needs room outside the drawing border to start from, or a reach inside it")?;
}
if let Some([length, width]) = self.trimming_mark_mm {
let fits = length.is_finite() && length <= MAX_SIDE_MM && width > 0.0 && width <= length;
require(fits, "a trimming mark is above 0, at most 5000 mm long and no wider than it is long")?;
require(width <= narrowest - g, "a trimming mark lies between the sheet border and the grid reference border")?;
}
Ok(())
}
/// The drawing border and the marks around it on `sheet`.
pub fn layout(&self, sheet: &Sheet) -> Result<SheetLayout> {
self.validate()?;
let size = sheet.size_mm()?;
let m = self.laid_out_margins();
let drawing_border = Aabb2 { min: Vec2::new(m.left_mm, m.bottom_mm), max: Vec2::new(size.x - m.right_mm, size.y - m.top_mm) };
let space = drawing_border.size();
require(space.x >= MIN_DRAWING_SPACE_MM && space.y >= MIN_DRAWING_SPACE_MM, "the margins leave no drawing space")?;
let (across, down) = match self.grid {
GridRule::None => (vec![], vec![]),
GridRule::FromCentre { field_mm } => (
from_centre(drawing_border.min.x, drawing_border.max.x, size.x / 2.0, field_mm)?,
from_centre(drawing_border.min.y, drawing_border.max.y, size.y / 2.0, field_mm)?,
),
GridRule::Equal { columns, rows } => {
(equal(drawing_border.min.x, drawing_border.max.x, columns), equal(drawing_border.min.y, drawing_border.max.y, rows))
}
};
let columns = fields(&across, |i| (i + 1).to_string());
// Rows run top to bottom: the edges are in rising y, so the top row comes last.
let mut rows = fields(&down, |_| String::new());
rows.reverse();
for (i, row) in rows.iter_mut().enumerate() {
row.label = letters(i, self.skip_i_and_o);
}
let sheet_border = Aabb2 { min: Vec2::ZERO, max: size };
let grid_border = drawing_border.inflated(self.grid_border_mm);
let start = match self.centring_mark_start {
CentringMarkStart::SheetEdge => sheet_border,
CentringMarkStart::GridBorder => grid_border,
};
let centring_marks = match self.centring_mark_mm {
Some(reach) => centring_marks(start, drawing_border, size, reach)?,
None => Vec::new(),
};
let trimming_marks = match self.trimming_mark_mm {
Some(mark) => trimming_marks(size, mark)?,
None => Vec::new(),
};
Ok(SheetLayout {
sheet_border,
drawing_border,
drawing_border_line_mm: self.drawing_border_line_mm,
columns,
rows,
label_sides: self.label_sides.on(sheet),
grid_border,
centring_marks,
trimming_marks,
})
}
}
/// The four centring marks: on the sheet's centre lines, from the edges of `start` to `reach`
/// inside the drawing border, which must not take them past the centre.
fn centring_marks(start: Aabb2, drawing_border: Aabb2, size: Vec2, reach: f64) -> Result<Vec<[Vec2; 2]>> {
let (cx, cy) = (size.x / 2.0, size.y / 2.0);
let (lo, hi) = (drawing_border.min, drawing_border.max);
let room = (cx - lo.x).min(hi.x - cx).min(cy - lo.y).min(hi.y - cy);
require(reach <= room, "a centring mark stops at the sheet's centre lines")?;
Ok(vec![
[Vec2::new(cx, start.min.y), Vec2::new(cx, lo.y + reach)],
[Vec2::new(cx, start.max.y), Vec2::new(cx, hi.y - reach)],
[Vec2::new(start.min.x, cy), Vec2::new(lo.x + reach, cy)],
[Vec2::new(start.max.x, cy), Vec2::new(hi.x - reach, cy)],
])
}
/// The trimming marks: at each corner of the sheet, a rectangle `length` along each edge and
/// `width` into the sheet, the two overlapping in the corner.
fn trimming_marks(size: Vec2, [length, width]: [f64; 2]) -> Result<Vec<Aabb2>> {
// Any longer and the two marks on one edge would meet.
require(length <= size.x.min(size.y) / 2.0, "a trimming mark is at most half the sheet's shorter side long")?;
// The span `extent` long from one end of `0..side`, the far end when `far`.
let span = |far: bool, side: f64, extent: f64| if far { (side - extent, side) } else { (0.0, extent) };
let mut marks = Vec::with_capacity(8);
for (right, top) in [(false, false), (true, false), (false, true), (true, true)] {
for (across, up) in [(length, width), (width, length)] {
let ((x0, x1), (y0, y1)) = (span(right, size.x, across), span(top, size.y, up));
marks.push(Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) });
}
}
Ok(marks)
}
/// The field edges from `lo` to `hi`, in rising order, for fields of `field` out from `centre`.
fn from_centre(lo: f64, hi: f64, centre: f64, field: f64) -> Result<Vec<f64>> {
let (below, above) = (centre - lo, hi - centre);
require(below > SLIVER_MM && above > SLIVER_MM, "the sheet's centre lines cross the drawing border")?;
// At most MAX_SIDE_MM / MIN_FIELD_MM per half: a bounded count.
let n = (below.max(above) / field).round().max(1.0);
let count = |half: f64| n.min(((half - SLIVER_MM) / field).ceil()) as usize;
let (k_below, k_above) = (count(below), count(above));
let mut edges = Vec::with_capacity(k_below + k_above + 1);
edges.push(lo);
edges.extend((1..k_below).rev().map(|j| centre - j as f64 * field));
edges.push(centre);
edges.extend((1..k_above).map(|j| centre + j as f64 * field));
edges.push(hi);
Ok(edges)
}
/// The edges of `n` equal fields from `lo` to `hi`, ending exactly at `hi`.
fn equal(lo: f64, hi: f64, n: u32) -> Vec<f64> {
let step = (hi - lo) / f64::from(n);
(0..n).map(|i| lo + f64::from(i) * step).chain(std::iter::once(hi)).collect()
}
fn fields(edges: &[f64], label: impl Fn(usize) -> String) -> Vec<Field> {
edges
.windows(2)
.enumerate()
.filter_map(|(i, w)| match *w {
[from_mm, to_mm] => Some(Field { from_mm, to_mm, label: label(i) }),
_ => None,
})
.collect()
}
/// The row label for row `i` (0 is A): A to Z, then AA, AB and on (without I and O if asked).
pub fn letters(i: usize, skip_i_and_o: bool) -> String {
const ALL: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const NO_I_O: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ";
let alphabet = if skip_i_and_o { NO_I_O } else { ALL };
let base = alphabet.len();
let mut out = Vec::new();
// Bijective numbering: after the last letter comes AA, not BA.
let mut n = i.saturating_add(1);
while n > 0 {
n -= 1;
out.extend(alphabet.get(n % base).map(|&b| char::from(b)));
n /= base;
}
out.iter().rev().collect()
}
#[cfg(test)]
#[path = "drawing_sheet_tests.rs"]
mod tests;
+611
View File
@@ -0,0 +1,611 @@
use super::*;
const EPS: f64 = 1e-9;
fn iso_layout(size: PaperSize, orientation: Orientation) -> SheetLayout {
DrawingBorderProfile::iso().layout(&Sheet::standard(size, orientation)).unwrap()
}
fn with_filing_margin() -> DrawingBorderProfile {
DrawingBorderProfile { filing_margin: true, ..DrawingBorderProfile::iso() }
}
/// ISO 5457's border with every choice on: the filing margin, centring marks from the grid
/// reference border to 10 mm inside the drawing border, and trimming marks.
fn full_iso_5457() -> DrawingBorderProfile {
DrawingBorderProfile {
centring_mark_mm: Some(10.0),
centring_mark_start: CentringMarkStart::GridBorder,
trimming_mark_mm: Some([10.0, 5.0]),
..with_filing_margin()
}
}
fn lengths(fields: &[Field]) -> Vec<f64> {
fields.iter().map(|f| f.to_mm - f.from_mm).collect()
}
fn close(a: &[f64], b: &[f64]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < EPS)
}
#[test]
fn every_size_is_its_published_millimetres() {
use PaperSize::*;
let expected = [
(A0, 841.0, 1189.0),
(A1, 594.0, 841.0),
(A2, 420.0, 594.0),
(A3, 297.0, 420.0),
(A4, 210.0, 297.0),
(A5, 148.0, 210.0),
(Letter, 215.9, 279.4),
(Legal, 215.9, 355.6),
(AnsiA, 215.9, 279.4),
(AnsiB, 279.4, 431.8),
(AnsiC, 431.8, 558.8),
(AnsiD, 558.8, 863.6),
(AnsiE, 863.6, 1117.6),
(AnsiF, 711.2, 1016.0),
(ArchA, 228.6, 304.8),
(ArchB, 304.8, 457.2),
(ArchC, 457.2, 609.6),
(ArchD, 609.6, 914.4),
(ArchE, 914.4, 1219.2),
(ArchE1, 762.0, 1066.8),
];
assert_eq!(expected.len(), PaperSize::ALL.len());
for (size, short, long) in expected {
let (s, l) = size.sides_mm();
assert!((s - short).abs() < EPS && (l - long).abs() < EPS, "{size:?}: {s} x {l}");
}
}
#[test]
fn landscape_is_the_default_and_either_orientation_turns_every_size() {
let sheet: Sheet = serde_json::from_value(serde_json::json!({"size": {"standard": "a3"}})).unwrap();
assert_eq!(sheet.orientation, Orientation::Landscape);
assert_eq!(sheet.size_mm().unwrap(), Vec2::new(420.0, 297.0));
for size in PaperSize::ALL {
let across = Sheet::standard(size, Orientation::Landscape).size_mm().unwrap();
let upright = Sheet::standard(size, Orientation::Portrait).size_mm().unwrap();
assert!(across.x > across.y && upright.x < upright.y, "{size:?}");
assert_eq!(across, Vec2::new(upright.y, upright.x));
}
// A custom sheet's sides may come in either order; the orientation lays it.
for sides_mm in [[500.0, 700.0], [700.0, 500.0]] {
let sheet = Sheet { size: SheetSize::Custom { sides_mm }, orientation: Orientation::Portrait };
assert_eq!(sheet.size_mm().unwrap(), Vec2::new(500.0, 700.0));
}
}
#[test]
fn a3_landscape_has_a_400_by_277_drawing_border_and_8_by_6_fields() {
let layout = iso_layout(PaperSize::A3, Orientation::Landscape);
assert_eq!(layout.sheet_border, Aabb2 { min: Vec2::ZERO, max: Vec2::new(420.0, 297.0) });
assert_eq!(layout.drawing_border, Aabb2 { min: Vec2::new(10.0, 10.0), max: Vec2::new(410.0, 287.0) });
assert_eq!(layout.drawing_border_line_mm, 0.7);
// Columns: 50 mm out from the centre line at 210, which is 200 mm from either side: four fields
// each way, and nothing left over for the corners.
assert!(close(&lengths(&layout.columns), &[50.0; 8]));
assert_eq!(layout.columns.iter().map(|f| f.label.as_str()).collect::<Vec<_>>(), ["1", "2", "3", "4", "5", "6", "7", "8"]);
assert!(layout.columns.iter().any(|f| f.to_mm == 210.0));
// Rows: 148.5 above and below 10 and 287, so two of 50 and a 38.5 mm corner each way.
assert!(close(&lengths(&layout.rows), &[38.5, 50.0, 50.0, 50.0, 50.0, 38.5]));
assert_eq!(layout.rows.iter().map(|f| f.label.as_str()).collect::<Vec<_>>(), ["A", "B", "C", "D", "E", "F"]);
let (top, bottom) = (layout.rows.first().unwrap(), layout.rows.last().unwrap());
assert_eq!((top.from_mm, top.to_mm), (248.5, 287.0));
assert_eq!((bottom.from_mm, bottom.to_mm), (10.0, 48.5));
assert_eq!(layout.label_sides, Sides::ALL);
}
#[test]
fn a3_landscape_has_a_grid_border_and_centring_marks_across_the_margin() {
let layout = iso_layout(PaperSize::A3, Orientation::Landscape);
// 5 mm outside the drawing border (10, 10)-(410, 287).
assert_eq!(layout.grid_border, Aabb2 { min: Vec2::new(5.0, 5.0), max: Vec2::new(415.0, 292.0) });
// On the centre lines x = 210 and y = 148.5, from the sheet's edge to the drawing border.
let mark = |a: (f64, f64), b: (f64, f64)| [Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)];
assert_eq!(
layout.centring_marks,
[
mark((210.0, 0.0), (210.0, 10.0)),
mark((210.0, 297.0), (210.0, 287.0)),
mark((0.0, 148.5), (10.0, 148.5)),
mark((420.0, 148.5), (410.0, 148.5))
]
);
// From the grid reference border instead, when the profile asks.
let iso_start = DrawingBorderProfile { centring_mark_start: CentringMarkStart::GridBorder, ..DrawingBorderProfile::iso() };
let from_grid = iso_start.layout(&Sheet::standard(PaperSize::A3, Orientation::Landscape)).unwrap();
assert_eq!(from_grid.centring_marks[0], mark((210.0, 5.0), (210.0, 10.0)));
assert_eq!(from_grid.centring_marks[3], mark((415.0, 148.5), (410.0, 148.5)));
assert!(layout.trimming_marks.is_empty());
// On the sheet's centre lines, not the drawing border's: a deeper bottom margin leaves them at 148.5.
let sheet = Sheet::standard(PaperSize::A3, Orientation::Landscape);
let iso = DrawingBorderProfile::iso();
let deep = DrawingBorderProfile { margins: Margins { bottom_mm: 30.0, ..iso.margins }, ..iso }.layout(&sheet).unwrap();
assert!(deep.centring_marks.iter().skip(2).all(|[a, b]| a.y == 148.5 && b.y == 148.5), "{:?}", deep.centring_marks);
// A profile can leave the centring marks out.
let plain = DrawingBorderProfile { centring_mark_mm: None, ..iso }.layout(&sheet).unwrap();
assert!(plain.centring_marks.is_empty());
assert_eq!(plain.grid_border, layout.grid_border);
}
/// A profile can still ask for all of ISO 5457's border: on A3 the drawing border, fields and marks
/// are the standard's.
#[test]
fn a3_landscape_with_iso_5457s_full_border_has_its_filing_margin_and_every_mark() {
let layout = full_iso_5457().layout(&Sheet::standard(PaperSize::A3, Orientation::Landscape)).unwrap();
// ISO 5457's 390 x 277 drawing space; the left corner field takes the 40 mm left over.
assert_eq!(layout.drawing_border, Aabb2 { min: Vec2::new(20.0, 10.0), max: Vec2::new(410.0, 287.0) });
assert!(close(&lengths(&layout.columns), &[40.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0]));
assert!(close(&lengths(&layout.rows), &[38.5, 50.0, 50.0, 50.0, 50.0, 38.5]));
// 5 mm outside the drawing border, the left side too.
assert_eq!(layout.grid_border, Aabb2 { min: Vec2::new(15.0, 5.0), max: Vec2::new(415.0, 292.0) });
// From the grid reference border to 10 mm inside the drawing border.
let mark = |a: (f64, f64), b: (f64, f64)| [Vec2::new(a.0, a.1), Vec2::new(b.0, b.1)];
assert_eq!(
layout.centring_marks,
[
mark((210.0, 5.0), (210.0, 20.0)),
mark((210.0, 292.0), (210.0, 277.0)),
mark((15.0, 148.5), (30.0, 148.5)),
mark((415.0, 148.5), (400.0, 148.5))
]
);
// At each corner, 10 along the bottom or top edge by 5 up or down, and 5 by 10 along the side.
let rect = |x0, y0, x1, y1| Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
assert_eq!(
layout.trimming_marks,
[
rect(0.0, 0.0, 10.0, 5.0),
rect(0.0, 0.0, 5.0, 10.0),
rect(410.0, 0.0, 420.0, 5.0),
rect(415.0, 0.0, 420.0, 10.0),
rect(0.0, 292.0, 10.0, 297.0),
rect(0.0, 287.0, 5.0, 297.0),
rect(410.0, 292.0, 420.0, 297.0),
rect(415.0, 287.0, 420.0, 297.0),
]
);
}
/// On every standard size either way, the marks are where the profile puts them: the built-in
/// centring marks run from the sheet's edge and end exactly on the drawing border, and there are no
/// trimming marks; ISO 5457's full border starts them at the grid reference border, reaches 10 mm
/// inside and has its trimming marks in the corners.
#[test]
fn every_sheet_has_centring_marks_on_its_centre_lines_and_trimming_marks_only_when_asked() {
for (profile, reach, trimming_marks) in [(DrawingBorderProfile::iso(), 0.0, 0), (full_iso_5457(), 10.0, 8)] {
for size in PaperSize::ALL {
for orientation in [Orientation::Landscape, Orientation::Portrait] {
let layout = profile.layout(&Sheet::standard(size, orientation)).unwrap();
let (size_mm, drawing, grid) = (layout.sheet_border.max, layout.drawing_border, layout.grid_border);
let centre = Vec2::new(size_mm.x / 2.0, size_mm.y / 2.0);
let start = match profile.centring_mark_start {
CentringMarkStart::SheetEdge => layout.sheet_border,
CentringMarkStart::GridBorder => grid,
};
// Bottom, top, left and right, from where the profile starts them.
let expected = [
[Vec2::new(centre.x, start.min.y), Vec2::new(centre.x, drawing.min.y + reach)],
[Vec2::new(centre.x, start.max.y), Vec2::new(centre.x, drawing.max.y - reach)],
[Vec2::new(start.min.x, centre.y), Vec2::new(drawing.min.x + reach, centre.y)],
[Vec2::new(start.max.x, centre.y), Vec2::new(drawing.max.x - reach, centre.y)],
];
assert_eq!(layout.centring_marks, expected, "{size:?} {orientation:?} {reach}");
assert_eq!(layout.trimming_marks.len(), trimming_marks, "{size:?} {orientation:?}");
for (i, r) in layout.trimming_marks.iter().enumerate() {
assert!(r.min.x >= 0.0 && r.min.y >= 0.0 && r.max.x <= size_mm.x && r.max.y <= size_mm.y, "{size:?}: {r:?}");
let on_corner = (r.min.x == 0.0 || r.max.x == size_mm.x) && (r.min.y == 0.0 || r.max.y == size_mm.y);
let shape = if i % 2 == 0 { Vec2::new(10.0, 5.0) } else { Vec2::new(5.0, 10.0) };
assert!(on_corner && r.size().dist(shape) < EPS, "{size:?} {orientation:?}: {r:?}");
// In the margins: up to the grid reference border, never across it.
assert!(!r.inflated(-EPS).meets(&grid), "{size:?} {orientation:?}: {r:?}");
}
}
}
}
}
/// The filing margin puts ISO 5457's 20 mm on the left in either orientation and moves nothing
/// else; a 20 mm left margin set by hand lays out the same.
#[test]
fn the_filing_margin_puts_20_mm_on_the_left_of_every_sheet() {
let iso = DrawingBorderProfile::iso();
let (filing, by_hand) = (with_filing_margin(), DrawingBorderProfile { margins: Margins { left_mm: 20.0, ..iso.margins }, ..iso });
// ISO 5457's drawing spaces: A3 across 390 x 277, A4 upright 180 x 277.
let a3 = filing.layout(&Sheet::standard(PaperSize::A3, Orientation::Landscape)).unwrap();
assert_eq!(a3.drawing_border, Aabb2 { min: Vec2::new(20.0, 10.0), max: Vec2::new(410.0, 287.0) });
assert!(close(&lengths(&a3.columns), &[40.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0]));
let a4 = filing.layout(&Sheet::standard(PaperSize::A4, Orientation::Portrait)).unwrap();
assert_eq!(a4.drawing_border, Aabb2 { min: Vec2::new(20.0, 10.0), max: Vec2::new(200.0, 287.0) });
// 85 mm left of the centre line at 105 and 95 right of it: two fields each way.
assert!(close(&lengths(&a4.columns), &[35.0, 50.0, 50.0, 45.0]));
for size in PaperSize::ALL {
for orientation in [Orientation::Landscape, Orientation::Portrait] {
let sheet = Sheet::standard(size, orientation);
let (on, off) = (filing.layout(&sheet).unwrap(), iso.layout(&sheet).unwrap());
assert_eq!(on, by_hand.layout(&sheet).unwrap(), "{size:?} {orientation:?}");
assert_eq!((on.drawing_border.min.x, off.drawing_border.min.x), (20.0, 10.0), "{size:?} {orientation:?}");
assert_eq!((on.drawing_border.min.y, on.drawing_border.max), (off.drawing_border.min.y, off.drawing_border.max));
assert_eq!(on.rows, off.rows, "{size:?} {orientation:?}");
}
}
// A left margin set by hand is kept for when the filing margin is off again.
let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape);
let wide = DrawingBorderProfile { margins: Margins { left_mm: 30.0, ..iso.margins }, ..iso };
assert_eq!(DrawingBorderProfile { filing_margin: true, ..wide }.layout(&a3).unwrap().drawing_border.min.x, 20.0);
assert_eq!(wide.layout(&a3).unwrap().drawing_border.min.x, 30.0);
}
#[test]
fn the_iso_rule_gives_the_standard_field_counts_for_a0_to_a4_either_way() {
// ISO 5457's table, long side by short side, with the filing margin or without.
for profile in [DrawingBorderProfile::iso(), with_filing_margin()] {
for (size, long, short) in
[(PaperSize::A0, 24, 16), (PaperSize::A1, 16, 12), (PaperSize::A2, 12, 8), (PaperSize::A3, 8, 6), (PaperSize::A4, 6, 4)]
{
let across = profile.layout(&Sheet::standard(size, Orientation::Landscape)).unwrap();
assert_eq!((across.columns.len(), across.rows.len()), (long, short), "{size:?} landscape {profile:?}");
let upright = profile.layout(&Sheet::standard(size, Orientation::Portrait)).unwrap();
assert_eq!((upright.columns.len(), upright.rows.len()), (short, long), "{size:?} portrait {profile:?}");
}
}
// A1 across: 410.5 mm each side of the centre line, so eight fields each way and 60.5 mm corners;
// the filing margin leaves 400.5 on the left, a 50.5 mm corner.
let a1 = Sheet::standard(PaperSize::A1, Orientation::Landscape);
for (profile, left) in [(DrawingBorderProfile::iso(), 60.5), (with_filing_margin(), 50.5)] {
let widths = lengths(&profile.layout(&a1).unwrap().columns);
assert!((widths.first().unwrap() - left).abs() < EPS && (widths.last().unwrap() - 60.5).abs() < EPS, "{widths:?}");
}
}
/// The label sides are worked out for each sheet, so one profile labels every size as ISO 5457 does.
#[test]
fn one_iso_profile_labels_a4_on_its_top_and_right_and_every_other_size_all_round() {
let iso = DrawingBorderProfile::iso();
let top_right = Sides { top: true, bottom: false, left: false, right: true };
let bottom_left = Sides { top: false, bottom: true, left: true, right: false };
let fixed = DrawingBorderProfile { label_sides: LabelSides::Fixed(bottom_left), ..iso };
for size in PaperSize::ALL {
for orientation in [Orientation::Portrait, Orientation::Landscape] {
let sheet = Sheet::standard(size, orientation);
let expected = if size == PaperSize::A4 { top_right } else { Sides::ALL };
assert_eq!(iso.layout(&sheet).unwrap().label_sides, expected, "{size:?} {orientation:?}");
assert_eq!(fixed.layout(&sheet).unwrap().label_sides, bottom_left, "{size:?} {orientation:?}");
}
}
}
#[test]
fn an_equal_division_cuts_the_drawing_border_evenly() {
let sheet = Sheet::standard(PaperSize::A3, Orientation::Landscape);
// With the filing margin, so the 390 mm across isn't a whole number of 50 mm fields.
let profile = DrawingBorderProfile { grid: GridRule::Equal { columns: 8, rows: 6 }, ..with_filing_margin() };
let layout = profile.layout(&sheet).unwrap();
assert!(close(&lengths(&layout.columns), &[48.75; 8]));
assert!(close(&lengths(&layout.rows), &[277.0 / 6.0; 6]));
assert!((277.0 / 6.0 - 46.166_666_666_7_f64).abs() < 1e-9);
let none = DrawingBorderProfile { grid: GridRule::None, ..profile }.layout(&sheet).unwrap();
assert!(none.columns.is_empty() && none.rows.is_empty());
assert_eq!(none.drawing_border, layout.drawing_border);
}
#[test]
fn rows_skip_i_and_o_and_letters_go_on_past_z() {
let a0 = iso_layout(PaperSize::A0, Orientation::Landscape);
let rows: String = a0.rows.iter().map(|f| f.label.as_str()).collect();
assert_eq!(rows, "ABCDEFGHJKLMNPQR");
assert_eq!(letters(0, true), "A");
assert_eq!(letters(23, true), "Z");
assert_eq!(letters(24, true), "AA");
assert_eq!(letters(25, true), "AB");
assert_eq!(letters(24 + 24 * 24 - 1, true), "ZZ");
assert_eq!(letters(24 + 24 * 24, true), "AAA");
assert_eq!(letters(8, false), "I");
assert_eq!(letters(25, false), "Z");
assert_eq!(letters(26, false), "AA");
assert_eq!(letters(usize::MAX, true).len(), letters(usize::MAX - 1, true).len());
for i in 0..2000 {
assert!(!letters(i, true).contains(['I', 'O']), "{i}");
}
let sheet = Sheet::standard(PaperSize::A3, Orientation::Landscape);
let every = DrawingBorderProfile { skip_i_and_o: false, grid: GridRule::Equal { columns: 1, rows: 10 }, ..DrawingBorderProfile::iso() };
let rows: String = every.layout(&sheet).unwrap().rows.iter().map(|f| f.label.as_str()).collect();
assert_eq!(rows, "ABCDEFGHIJ");
}
#[test]
fn hostile_sizes_and_profiles_are_refused() {
let custom = |a: f64, b: f64| Sheet { size: SheetSize::Custom { sides_mm: [a, b] }, orientation: Orientation::Landscape };
for (a, b) in [(0.0, 100.0), (-1.0, 100.0), (f64::NAN, 100.0), (100.0, f64::INFINITY), (5_000.000_001, 100.0), (f64::MIN_POSITIVE, f64::MAX)] {
assert!(custom(a, b).size_mm().is_err(), "{a} x {b}");
}
assert_eq!(custom(5000.0, 5000.0).size_mm().unwrap(), Vec2::new(5000.0, 5000.0));
let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape);
let iso = DrawingBorderProfile::iso();
let with_margins =
|left_mm, right_mm, top_mm, bottom_mm| DrawingBorderProfile { margins: Margins { left_mm, right_mm, top_mm, bottom_mm }, ..iso };
// Refused on its own, with no sheet at all: validate() refuses it, and so layout() does too.
for profile in [
with_margins(-1.0, 10.0, 10.0, 10.0),
with_margins(f64::NAN, 10.0, 10.0, 10.0),
with_margins(20.0, f64::INFINITY, 10.0, 10.0),
with_margins(20.0, 10.0, 5000.1, 10.0),
// The left margin is checked while the filing margin stands in for it.
DrawingBorderProfile { filing_margin: true, ..with_margins(f64::NAN, 10.0, 10.0, 10.0) },
DrawingBorderProfile { filing_margin: true, ..with_margins(-1.0, 10.0, 10.0, 10.0) },
DrawingBorderProfile { drawing_border_line_mm: 0.0, ..iso },
DrawingBorderProfile { drawing_border_line_mm: -0.7, ..iso },
DrawingBorderProfile { drawing_border_line_mm: f64::NAN, ..iso },
DrawingBorderProfile { drawing_border_line_mm: 10.000_001, ..iso },
DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: 0.999 }, ..iso },
DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: 1000.1 }, ..iso },
DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: f64::NAN }, ..iso },
DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: f64::INFINITY }, ..iso },
DrawingBorderProfile { grid: GridRule::Equal { columns: 0, rows: 6 }, ..iso },
DrawingBorderProfile { grid: GridRule::Equal { columns: 8, rows: 0 }, ..iso },
DrawingBorderProfile { grid: GridRule::Equal { columns: 201, rows: 1 }, ..iso },
DrawingBorderProfile { grid: GridRule::Equal { columns: u32::MAX, rows: u32::MAX }, ..iso },
// The grid reference border lies between the sheet border and the drawing border.
DrawingBorderProfile { grid_border_mm: -0.1, ..iso },
DrawingBorderProfile { grid_border_mm: f64::NAN, ..iso },
DrawingBorderProfile { grid_border_mm: 10.000_001, ..iso },
// On every side: 5 mm is wider than a 4 mm bottom margin, or a 4 mm left one without the
// filing margin.
with_margins(20.0, 10.0, 10.0, 4.0),
with_margins(4.0, 10.0, 10.0, 10.0),
// A centring mark ends on the drawing border or inside it...
DrawingBorderProfile { centring_mark_mm: Some(-0.000_001), ..iso },
DrawingBorderProfile { centring_mark_mm: Some(-10.0), ..iso },
DrawingBorderProfile { centring_mark_mm: Some(f64::NAN), ..iso },
DrawingBorderProfile { centring_mark_mm: Some(f64::INFINITY), ..iso },
// No mark is longer than the largest sheet's side, so a file's huge number is refused when
// read rather than saved as one the next read refuses.
DrawingBorderProfile { centring_mark_mm: Some(5_000.000_001), ..iso },
DrawingBorderProfile { centring_mark_mm: Some(u64::MAX as f64), ..iso },
// ...and is longer than 0: one that stops on the drawing border needs room outside it on
// the side it starts from, a grid reference border or every margin.
DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_start: CentringMarkStart::GridBorder, ..iso },
DrawingBorderProfile { grid_border_mm: 0.0, ..with_margins(0.0, 10.0, 10.0, 10.0) },
// A trimming mark is finite, above 0 and no wider than it is long...
DrawingBorderProfile { trimming_mark_mm: Some([4.0, 5.0]), ..iso },
DrawingBorderProfile { trimming_mark_mm: Some([10.0, 0.0]), ..iso },
DrawingBorderProfile { trimming_mark_mm: Some([f64::NAN, 5.0]), ..iso },
DrawingBorderProfile { trimming_mark_mm: Some([10.0, f64::NAN]), ..iso },
DrawingBorderProfile { trimming_mark_mm: Some([f64::INFINITY, 5.0]), ..iso },
DrawingBorderProfile { trimming_mark_mm: Some([5_000.000_001, 5.0]), ..iso },
DrawingBorderProfile { trimming_mark_mm: Some([u64::MAX as f64, 5.0]), ..iso },
// ...and fits between the sheet border and the grid reference border: ISO 5457's 5 mm fill
// the 10 - 5 mm left exactly.
DrawingBorderProfile { grid_border_mm: 5.000_001, ..full_iso_5457() },
DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_mm: None, trimming_mark_mm: Some([10.0, 5.0]), ..with_margins(2.0, 2.0, 2.0, 2.0) },
] {
assert!(profile.validate().is_err(), "validated {profile:?}");
assert!(profile.layout(&a3).is_err(), "laid out {profile:?}");
}
// Fine on its own, but not on an A3 sheet.
for profile in [
with_margins(210.0, 210.0, 10.0, 10.0),
with_margins(20.0, 10.0, 148.0, 148.5),
// A millimetre of drawing space, but the centre line at 210 lies outside it.
with_margins(200.0, 219.0, 10.0, 10.0),
// A centring mark past the centre line, 138.5 mm inside the drawing border's bottom.
DrawingBorderProfile { centring_mark_mm: Some(138.500_001), ..iso },
// Or a drawing border its centre lines miss.
DrawingBorderProfile { grid: GridRule::Equal { columns: 2, rows: 2 }, ..with_margins(200.0, 219.0, 10.0, 10.0) },
// Or one whose left side lies on the centre line at 210, with any reach inside it.
DrawingBorderProfile {
grid: GridRule::Equal { columns: 2, rows: 2 },
centring_mark_mm: Some(0.000_001),
..with_margins(210.0, 10.0, 10.0, 10.0)
},
// A trimming mark longer than half the 297 mm side.
DrawingBorderProfile { trimming_mark_mm: Some([148.500_001, 5.0]), ..iso },
// The longest marks a profile may have, on the largest sheet only.
DrawingBorderProfile { centring_mark_mm: Some(5000.0), ..iso },
DrawingBorderProfile { trimming_mark_mm: Some([5000.0, 5.0]), ..iso },
] {
assert_eq!(profile.validate(), Ok(()), "{profile:?}");
assert!(profile.layout(&a3).is_err(), "laid out {profile:?}");
}
// The same space with an equal division and no centring marks is fine, and so are the limits
// themselves.
let off_centre =
DrawingBorderProfile { grid: GridRule::Equal { columns: 2, rows: 2 }, centring_mark_mm: None, ..with_margins(200.0, 219.0, 10.0, 10.0) };
assert!((off_centre.layout(&a3).unwrap().drawing_border.max.x - 201.0).abs() < EPS);
for profile in [
DrawingBorderProfile { drawing_border_line_mm: 10.0, ..iso },
DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: 1.0 }, ..iso },
DrawingBorderProfile { grid: GridRule::FromCentre { field_mm: 1000.0 }, ..iso },
DrawingBorderProfile { grid: GridRule::Equal { columns: 200, rows: 200 }, ..iso },
DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_mm: None, ..with_margins(0.0, 0.0, 0.0, 0.0) },
DrawingBorderProfile { grid_border_mm: 10.0, ..iso },
DrawingBorderProfile { centring_mark_mm: Some(138.5), ..iso },
// With no grid reference border, a centring mark that reaches inside has a length, and so
// does one from the sheet's edge.
DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_mm: Some(10.0), centring_mark_start: CentringMarkStart::GridBorder, ..iso },
DrawingBorderProfile { grid_border_mm: 0.0, ..iso },
// A centre line on the drawing border's left side: a mark that stops on the border fits.
DrawingBorderProfile { grid: GridRule::Equal { columns: 2, rows: 2 }, ..with_margins(210.0, 10.0, 10.0, 10.0) },
// The filing margin stands in for a left margin too narrow for the grid reference border.
DrawingBorderProfile { filing_margin: true, ..with_margins(4.0, 10.0, 10.0, 10.0) },
iso,
// ISO 5457's trimming marks fill their strip exactly, and so do these.
full_iso_5457(),
DrawingBorderProfile { grid_border_mm: 0.0, centring_mark_mm: None, trimming_mark_mm: Some([10.0, 2.0]), ..with_margins(2.0, 2.0, 2.0, 2.0) },
DrawingBorderProfile { trimming_mark_mm: Some([148.5, 5.0]), ..iso },
] {
profile.layout(&a3).unwrap();
}
}
#[test]
fn names_round_trip_and_unknown_fields_are_refused() {
for (size, name) in
[(PaperSize::A0, "a0"), (PaperSize::A5, "a5"), (PaperSize::Letter, "letter"), (PaperSize::AnsiA, "ansi_a"), (PaperSize::ArchE1, "arch_e1")]
{
assert_eq!(serde_json::to_value(size).unwrap(), serde_json::json!(name));
}
for size in PaperSize::ALL {
let back: PaperSize = serde_json::from_value(serde_json::to_value(size).unwrap()).unwrap();
assert_eq!(back, size);
}
let sheet = Sheet { size: SheetSize::Custom { sides_mm: [500.0, 700.0] }, orientation: Orientation::Portrait };
let value = serde_json::to_value(sheet).unwrap();
assert_eq!(value, serde_json::json!({"size": {"custom": {"sides_mm": [500.0, 700.0]}}, "orientation": "portrait"}));
assert_eq!(serde_json::from_value::<Sheet>(value).unwrap(), sheet);
let profile = DrawingBorderProfile::iso();
let value = serde_json::to_value(profile).unwrap();
assert_eq!(value["margins"], serde_json::json!({"left_mm": 10.0, "right_mm": 10.0, "top_mm": 10.0, "bottom_mm": 10.0}));
assert_eq!(value["filing_margin"], serde_json::json!(false));
assert_eq!(value["grid"], serde_json::json!({"from_centre": {"field_mm": 50.0}}));
assert_eq!(value["label_sides"], serde_json::json!("iso"));
assert_eq!(value["centring_mark_mm"], serde_json::json!(0.0));
assert_eq!(value["centring_mark_start"], serde_json::json!("sheet_edge"));
// Not given, it is the sheet's edge; an unknown start is refused.
let mut without = value.clone();
without.as_object_mut().unwrap().remove("centring_mark_start");
assert_eq!(serde_json::from_value::<DrawingBorderProfile>(without).unwrap(), profile);
let mut unknown = value.clone();
unknown["centring_mark_start"] = serde_json::json!("middle");
assert!(serde_json::from_value::<DrawingBorderProfile>(unknown).is_err());
assert_eq!(value["trimming_mark_mm"], serde_json::json!(null));
assert_eq!(serde_json::to_value(GridRule::None).unwrap(), serde_json::json!("none"));
assert_eq!(serde_json::from_value::<DrawingBorderProfile>(value.clone()).unwrap(), profile);
for other in [
DrawingBorderProfile { grid: GridRule::None, ..profile },
DrawingBorderProfile { grid: GridRule::Equal { columns: 8, rows: 6 }, ..profile },
DrawingBorderProfile { label_sides: LabelSides::Fixed(Sides::ALL), ..profile },
full_iso_5457(),
] {
assert_eq!(serde_json::from_value::<DrawingBorderProfile>(serde_json::to_value(other).unwrap()).unwrap(), other);
}
// A profile written without the filing margin reads with it off.
let mut without = value.clone();
without.as_object_mut().unwrap().remove("filing_margin");
assert_eq!(serde_json::from_value::<DrawingBorderProfile>(without).unwrap(), profile);
for bad in [
serde_json::json!({"size": {"standard": "a6"}}),
serde_json::json!({"size": {"standard": "a3"}, "scale": 1}),
serde_json::json!({"size": {"custom": {"sides_mm": [1, 2], "units": "in"}}}),
serde_json::json!({"size": {"custom": {"sides_mm": [1, 2, 3]}}}),
serde_json::json!({"size": {"standard": "a3"}, "orientation": "upside_down"}),
] {
assert!(serde_json::from_value::<Sheet>(bad.clone()).is_err(), "{bad}");
}
// An unknown field anywhere in a profile is refused, in the grid's `none` form too.
for (key, bad) in [
("title_block", serde_json::json!(true)),
("grid", serde_json::json!({"rule": "none", "columns": 8})),
("grid", serde_json::json!({"none": {"columns": 8}})),
("grid", serde_json::json!({"from_centre": {"field_mm": 50.0, "columns": 8}})),
("grid", serde_json::json!({"from_centre": {"field_mm": 50.0}, "columns": 8})),
("label_sides", serde_json::json!({"fixed": {"top": true, "bottom": true, "left": true, "right": true, "middle": true}})),
] {
let mut extra = value.clone();
extra[key] = bad.clone();
assert!(serde_json::from_value::<DrawingBorderProfile>(extra).is_err(), "{key}: {bad}");
}
}
/// For every accepted sheet and profile, the fields tile the drawing border exactly, top to bottom
/// and left to right, every field is longer than nothing and nothing is NaN.
#[test]
fn fields_tile_every_drawing_border_exactly() {
let mut sheets: Vec<Sheet> =
PaperSize::ALL.into_iter().flat_map(|size| [Orientation::Landscape, Orientation::Portrait].map(|o| Sheet::standard(size, o))).collect();
for sides_mm in [[1.5, 1.5], [5000.0, 5000.0], [5000.0, 1.0], [17.3, 4999.9], [100.0, 101.0], [123.456, 789.012]] {
for orientation in [Orientation::Landscape, Orientation::Portrait] {
sheets.push(Sheet { size: SheetSize::Custom { sides_mm }, orientation });
}
}
let margins = [
Margins { left_mm: 20.0, right_mm: 10.0, top_mm: 10.0, bottom_mm: 10.0 },
Margins { left_mm: 0.0, right_mm: 0.0, top_mm: 0.0, bottom_mm: 0.0 },
Margins { left_mm: 5.0, right_mm: 5.0, top_mm: 5.0, bottom_mm: 5.0 },
Margins { left_mm: 33.3, right_mm: 0.1, top_mm: 7.7, bottom_mm: 19.0 },
];
let grids = [
GridRule::FromCentre { field_mm: 50.0 },
GridRule::FromCentre { field_mm: 1.0 },
GridRule::FromCentre { field_mm: 7.5 },
GridRule::FromCentre { field_mm: 33.3 },
GridRule::FromCentre { field_mm: 1000.0 },
GridRule::Equal { columns: 1, rows: 1 },
GridRule::Equal { columns: 3, rows: 7 },
GridRule::Equal { columns: 200, rows: 200 },
];
let mut accepted = 0;
for sheet in &sheets {
for (margins, filing_margin) in margins.into_iter().flat_map(|m| [(m, false), (m, true)]) {
for grid in grids {
let profile = DrawingBorderProfile {
margins,
filing_margin,
drawing_border_line_mm: 0.7,
grid,
skip_i_and_o: true,
label_sides: LabelSides::Fixed(Sides::ALL),
grid_border_mm: 0.0,
centring_mark_mm: None,
centring_mark_start: CentringMarkStart::SheetEdge,
trimming_mark_mm: None,
};
let Ok(layout) = profile.layout(sheet) else { continue };
accepted += 1;
let (drawing, size_mm) = (layout.drawing_border, layout.sheet_border.max);
assert!(drawing.min.x >= 0.0 && drawing.min.y >= 0.0 && drawing.max.x <= size_mm.x && drawing.max.y <= size_mm.y);
let check = |fields: &[Field], lo: f64, hi: f64, falling: bool| {
let (first, last) = (fields.first().unwrap(), fields.last().unwrap());
let (start, end) = if falling { (last.from_mm, first.to_mm) } else { (first.from_mm, last.to_mm) };
assert_eq!((start, end), (lo, hi), "{sheet:?} {profile:?}");
for f in fields {
assert!(f.from_mm.is_finite() && f.to_mm.is_finite() && f.to_mm - f.from_mm > EPS, "{f:?} {sheet:?} {profile:?}");
}
for w in fields.windows(2) {
let (a, b) = (&w[0], &w[1]);
if falling { assert_eq!(a.from_mm, b.to_mm) } else { assert_eq!(a.to_mm, b.from_mm) }
}
let total: f64 = fields.iter().map(|f| f.to_mm - f.from_mm).sum();
assert!((total - (hi - lo)).abs() < 1e-6, "{total} vs {}", hi - lo);
let mut labels: Vec<&str> = fields.iter().map(|f| f.label.as_str()).collect();
labels.sort_unstable();
labels.dedup();
assert_eq!(labels.len(), fields.len());
};
check(&layout.columns, drawing.min.x, drawing.max.x, false);
check(&layout.rows, drawing.min.y, drawing.max.y, true);
if let GridRule::FromCentre { field_mm } = grid {
// Inner fields are exactly the field length, and the centre lines are edges.
let centre = size_mm.x / 2.0;
assert!(layout.columns.iter().any(|f| f.to_mm == centre));
let inner = layout.columns.len().saturating_sub(2);
for f in layout.columns.iter().skip(1).take(inner) {
assert!((f.to_mm - f.from_mm - field_mm).abs() < 1e-9, "{f:?}");
}
}
}
}
}
assert!(accepted > 1000, "{accepted}");
}
#[test]
fn rounding_past_a_whole_number_of_fields_leaves_no_sliver() {
// 420 mm across with the centre line at 210 and the drawing border to 410: the right half is
// four fields of 50. The left margin puts the left half at 150 mm, three fields, give or take
// rounding noise.
let sheet = Sheet { size: SheetSize::Custom { sides_mm: [420.0, 297.0] }, orientation: Orientation::Landscape };
for hair in [0.0, 1e-12, 1e-10, -1e-10] {
let margins = Margins { left_mm: 60.0 - hair, right_mm: 10.0, top_mm: 10.0, bottom_mm: 10.0 };
let layout = DrawingBorderProfile { margins, ..DrawingBorderProfile::iso() }.layout(&sheet).unwrap();
let widths = lengths(&layout.columns);
// The noise joins the corner field: no field of a hair's width before it.
assert_eq!(widths.len(), 7, "{hair}: {widths:?}");
assert!(close(&widths, &[50.0; 7]), "{hair}: {widths:?}");
assert_eq!(layout.columns.first().unwrap().from_mm, 60.0 - hair);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! Drawing views as data (W17a, #4816): which way a view looks, its scale, and the map from the
//! model's millimetres to the paper's. Nothing here draws or measures; the view commands (W07)
//! and the hidden-line and section producers feed it.
//!
//! - A view's **basis** is a [`Plane`]: paper x along `x`, paper y along `y`, and its normal
//! `x × y` toward the viewer. [`NamedView`] gives the principal views and the four upper
//! isometric corners exactly, never from a camera, which tilts a top view by its pitch clamp;
//! [`basis_looking`] gives any other view from its direction and up.
//! - A **[`Scale`]** is a ratio of two numbers from 0.000001 to 1000000, written `"1:2"` (paper :
//! model), from 1:100000 to 1000:1. [`Scale::fit`] picks the largest of ISO 5455's preset
//! scales at which a view fits a space.
//! - A **[`ViewFrame`]** places a view on paper: the point of the view's plane at `centre` lands at
//! `position_mm`, everything scaled by the scale. Its [`ViewFrame::paper_map`] moves the view's
//! curves ([`Seg2::similar`]) and their accuracy ([`CurveAccuracy::scaled`]) onto paper.
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::paper::MAX_SIDE_MM;
use crate::section::{CurveAccuracy, MAX_NUMBER, validate_plane};
use crate::{Aabb2, Plane, Seg2, Similarity2, Vec2, Vec3};
/// The smallest scale: one paper millimetre for 100 m of model.
pub const MIN_SCALE: f64 = 1e-5;
/// The largest scale: 1000 paper millimetres for one of model.
pub const MAX_SCALE: f64 = 1000.0;
/// The smallest and largest number either side of a scale may be: `"1:100000"` and `"1000:1"`
/// with room to spare, and never more than about 50 characters written out.
pub const MIN_PART: f64 = 1e-6;
pub const MAX_PART: f64 = 1e6;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("drawing view: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
/// Two directions closer to parallel than this (the sine of their angle) give no view.
const PARALLEL: f64 = 1e-9;
/// A view with a name: the six principal views and the isometric views from the four upper
/// corners. Each looks at the model from where its name says; `iso` reads as the front-right one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NamedView {
Front,
Back,
Top,
Bottom,
Left,
Right,
#[serde(alias = "iso")]
IsoFrontRight,
IsoFrontLeft,
IsoBackRight,
IsoBackLeft,
}
impl NamedView {
pub const ALL: [NamedView; 10] = [
NamedView::Front,
NamedView::Back,
NamedView::Top,
NamedView::Bottom,
NamedView::Left,
NamedView::Right,
NamedView::IsoFrontRight,
NamedView::IsoFrontLeft,
NamedView::IsoBackRight,
NamedView::IsoBackLeft,
];
/// The view's basis, through the world origin. The principal views' axes are exact unit
/// vectors, with no -0 to write out, each the front view folded about the edge it shares with
/// it: the top and bottom views keep paper x along +X (the bottom's y is -Y), and the back and
/// side views keep paper y along +Z. An isometric view looks from its corner, `(±1, ±1, 1)`,
/// toward the origin, with paper x horizontal (along the view direction × Z) and paper y up.
pub fn basis(self) -> Plane {
let (x, y) = match self {
NamedView::Front => (Vec3::X, Vec3::Z),
NamedView::Back => (Vec3::new(-1.0, 0.0, 0.0), Vec3::Z),
NamedView::Top => (Vec3::X, Vec3::Y),
NamedView::Bottom => (Vec3::X, Vec3::new(0.0, -1.0, 0.0)),
NamedView::Left => (Vec3::new(0.0, -1.0, 0.0), Vec3::Z),
NamedView::Right => (Vec3::Y, Vec3::Z),
NamedView::IsoFrontRight => iso(1.0, -1.0),
NamedView::IsoFrontLeft => iso(-1.0, -1.0),
NamedView::IsoBackRight => iso(1.0, 1.0),
NamedView::IsoBackLeft => iso(-1.0, 1.0),
};
Plane { origin: Vec3::ZERO, x, y }
}
}
/// The view through `origin` looking along `direction`, with `up` toward the top of the paper:
/// paper x = direction × up, paper y = x × direction, so the normal points back at the viewer as
/// the named views' do. `up` need not be square to the direction, only not along it.
pub fn basis_looking(direction: Vec3, up: Vec3, origin: Vec3) -> Result<Plane> {
let d = direction.normalized().ok_or(Invalid("the view direction is zero or not finite"))?;
let up = up.normalized().ok_or(Invalid("`up` is zero or not finite"))?;
let across = d.cross(up);
require(across.len() >= PARALLEL, "`up` lies along the view direction: give another up")?;
require(
origin.is_finite() && [origin.x, origin.y, origin.z].iter().all(|v| v.abs() <= MAX_NUMBER),
"a view's origin is finite and within 1e12 mm of the world's",
)?;
// Square to the direction again: near the threshold the cross product's rounding is a large
// share of its length, which would tilt the normal by up to 1e-8 rad.
let across = across - d * across.dot(d);
let x = across * (1.0 / across.len());
let basis = Plane { origin, x, y: x.cross(d) };
validate_plane(&basis).map_err(|_| Invalid("a view's basis is finite and orthonormal"))?;
Ok(basis)
}
/// Paper x and y for an isometric view from the upper corner `(cx, cy, 1)`: x is horizontal, the
/// view direction × Z, and y = x × direction, so the normal points back at the corner.
fn iso(cx: f64, cy: f64) -> (Vec3, Vec3) {
let toward = Vec3::new(-cx, -cy, -1.0) * (1.0 / 3f64.sqrt());
let x = Vec3::new(-cy, cx, 0.0) * (1.0 / 2f64.sqrt());
(x, x.cross(toward))
}
/// A drawing scale, paper : model, as two positive numbers so `"1:3"` stays exactly what was
/// written. Read and written as that text.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Scale {
paper: f64,
model: f64,
}
/// ISO 5455's recommended scales, largest first: enlargements, full size, then reductions.
pub const PRESETS: [Scale; 18] = [
Scale { paper: 50.0, model: 1.0 },
Scale { paper: 20.0, model: 1.0 },
Scale { paper: 10.0, model: 1.0 },
Scale { paper: 5.0, model: 1.0 },
Scale { paper: 2.0, model: 1.0 },
Scale { paper: 1.0, model: 1.0 },
Scale { paper: 1.0, model: 2.0 },
Scale { paper: 1.0, model: 5.0 },
Scale { paper: 1.0, model: 10.0 },
Scale { paper: 1.0, model: 20.0 },
Scale { paper: 1.0, model: 50.0 },
Scale { paper: 1.0, model: 100.0 },
Scale { paper: 1.0, model: 200.0 },
Scale { paper: 1.0, model: 500.0 },
Scale { paper: 1.0, model: 1000.0 },
Scale { paper: 1.0, model: 2000.0 },
Scale { paper: 1.0, model: 5000.0 },
Scale { paper: 1.0, model: 10000.0 },
];
impl Scale {
/// `paper : model`, each from [`MIN_PART`] to [`MAX_PART`] (so the text stays short), their
/// ratio from [`MIN_SCALE`] to [`MAX_SCALE`].
pub fn new(paper: f64, model: f64) -> Result<Scale> {
let parts = [paper, model].into_iter().all(|v| (MIN_PART..=MAX_PART).contains(&v));
let message = "a scale is two numbers from 0.000001 to 1000000, from 1:100000 to 1000:1";
require(parts && (MIN_SCALE..=MAX_SCALE).contains(&(paper / model)), message)?;
Ok(Scale { paper, model })
}
/// Paper millimetres per model millimetre.
pub fn factor(self) -> f64 {
self.paper / self.model
}
/// The largest preset at which a view `size_mm` wide and high (in model millimetres) fits
/// within `room_mm` on paper, if any does. Compared as `size * paper <= room * model`, so an
/// exact fit at 1:5 isn't lost to 0.2's rounding. A size of 0 sets no limit that way (a view of
/// a line along the other), so a view of a point fits at the largest preset; a size or room
/// that is negative or not finite fits none.
pub fn fit(size_mm: Vec2, room_mm: Vec2) -> Option<Scale> {
if ![size_mm, room_mm].into_iter().all(|v| v.is_finite() && v.x >= 0.0 && v.y >= 0.0) {
return None;
}
PRESETS.into_iter().find(|s| size_mm.x * s.paper <= room_mm.x * s.model && size_mm.y * s.paper <= room_mm.y * s.model)
}
}
impl fmt::Display for Scale {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.paper, self.model)
}
}
/// Plain decimal numbers only (`2`, `2.5`): digits, then at most one point with digits after it;
/// no sign, exponent or space.
fn decimal(text: &str) -> Option<f64> {
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
let plain = match text.split_once('.') {
Some((whole, fraction)) => digits(whole) && digits(fraction),
None => digits(text),
};
plain.then(|| text.parse().ok()).flatten()
}
impl FromStr for Scale {
type Err = Invalid;
fn from_str(text: &str) -> Result<Scale> {
let parts = text.split_once(':').and_then(|(p, m)| Some((decimal(p)?, decimal(m)?)));
let (paper, model) = parts.ok_or(Invalid("a scale is written paper:model, such as 1:2"))?;
Scale::new(paper, model)
}
}
impl Serialize for Scale {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Scale {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Scale, D::Error> {
String::deserialize(d)?.parse().map_err(serde::de::Error::custom)
}
}
/// A view placed on paper: its basis, its scale, and where it sits.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ViewFrame {
basis: Plane,
scale: Scale,
map: Similarity2,
}
impl ViewFrame {
/// The view through `basis` at `scale`, with the point `centre` of the view's plane (in its
/// own coordinates, [`Plane::to_local`]) at `position_mm` on paper.
pub fn new(basis: Plane, scale: Scale, centre: Vec2, position_mm: Vec2) -> Result<ViewFrame> {
validate_plane(&basis).map_err(|_| Invalid("a view's basis is finite and orthonormal"))?;
require(centre.is_finite() && centre.x.abs() <= MAX_NUMBER && centre.y.abs() <= MAX_NUMBER, "a view's centre is finite")?;
let on_paper = position_mm.is_finite() && position_mm.x.abs() <= MAX_SIDE_MM && position_mm.y.abs() <= MAX_SIDE_MM;
require(on_paper, "a view's position is finite and at most 5000 mm from the sheet's origin")?;
let factor = scale.factor();
let map = Similarity2::new(factor, 0.0, position_mm - centre * factor).ok_or(Invalid("a view's position is finite"))?;
Ok(ViewFrame { basis, scale, map })
}
pub fn basis(&self) -> Plane {
self.basis
}
pub fn scale(&self) -> Scale {
self.scale
}
/// The map from the view's plane coordinates to paper millimetres, for its curves. Plane
/// coordinates are the plane's own ([`Plane::to_local`]: from the basis's origin along its x
/// and y), as section curves are. Points measured along x and y from the world origin (as the
/// hidden-line output is planned to be) first lose the origin's own `(origin · x, origin · y)`;
/// a named view's origin is the world origin, so for it the two are the same.
pub fn paper_map(&self) -> Similarity2 {
self.map
}
/// Where a model point appears on paper.
pub fn to_paper(&self, p: Vec3) -> Vec2 {
self.map.apply(self.basis.to_local(p))
}
/// A box of the view's plane (in plane coordinates, as [`Self::paper_map`]'s) on paper: the box
/// around its four corners, mapped. A box that is inside out, or not finite on paper, has none.
pub fn boundary(&self, model: &Aabb2) -> Result<Aabb2> {
let corners = [model.min, Vec2::new(model.max.x, model.min.y), model.max, Vec2::new(model.min.x, model.max.y)].map(|c| self.map.apply(c));
require(!model.is_empty() && corners.iter().all(|c| c.is_finite()), "a view's box is not inside out and is finite on paper")?;
Ok(corners.into_iter().collect())
}
/// A curve of the view's plane (in plane coordinates, as [`Self::paper_map`]'s), on paper.
pub fn seg_to_paper(&self, seg: &Seg2) -> Seg2 {
seg.similar(&self.map)
}
/// A curve's accuracy on paper: its deviation scaled as the curve is.
pub fn accuracy_on_paper(&self, accuracy: CurveAccuracy) -> CurveAccuracy {
accuracy.scaled(self.map.scale())
}
}
#[cfg(test)]
#[path = "drawing_view_tests.rs"]
mod tests;
+273
View File
@@ -0,0 +1,273 @@
use super::*;
use crate::section::validate_plane;
const EPS: f64 = 1e-12;
fn close(a: Vec3, b: Vec3) -> bool {
(a - b).len() < EPS
}
/// A 50 mm cube's corners, from the origin.
fn cube() -> Vec<Vec3> {
let s = 50.0;
(0..8).map(|i| Vec3::new(f64::from(i & 1) * s, f64::from((i >> 1) & 1) * s, f64::from((i >> 2) & 1) * s)).collect()
}
/// The paper extents (width, height) of points seen through a frame.
fn extents(frame: &ViewFrame, points: &[Vec3]) -> Vec2 {
let paper: Vec<Vec2> = points.iter().map(|p| frame.to_paper(*p)).collect();
let span = |f: fn(&Vec2) -> f64| {
let values = paper.iter().map(f);
values.clone().fold(f64::NEG_INFINITY, f64::max) - values.fold(f64::INFINITY, f64::min)
};
Vec2::new(span(|p| p.x), span(|p| p.y))
}
fn full_size(view: NamedView) -> ViewFrame {
ViewFrame::new(view.basis(), Scale::new(1.0, 1.0).unwrap(), Vec2::ZERO, Vec2::ZERO).unwrap()
}
/// Every named view is an exact, right-handed, orthonormal basis whose normal points back at the
/// viewer: the principal views from where their names say, the isometric views from their
/// upper corners with paper x horizontal.
#[test]
fn each_named_view_looks_from_where_its_name_says() {
let viewer = [
(NamedView::Front, Vec3::new(0.0, -1.0, 0.0)),
(NamedView::Back, Vec3::Y),
(NamedView::Top, Vec3::Z),
(NamedView::Bottom, Vec3::new(0.0, 0.0, -1.0)),
(NamedView::Left, Vec3::new(-1.0, 0.0, 0.0)),
(NamedView::Right, Vec3::X),
(NamedView::IsoFrontRight, Vec3::new(1.0, -1.0, 1.0) * (1.0 / 3f64.sqrt())),
(NamedView::IsoFrontLeft, Vec3::new(-1.0, -1.0, 1.0) * (1.0 / 3f64.sqrt())),
(NamedView::IsoBackRight, Vec3::new(1.0, 1.0, 1.0) * (1.0 / 3f64.sqrt())),
(NamedView::IsoBackLeft, Vec3::new(-1.0, 1.0, 1.0) * (1.0 / 3f64.sqrt())),
];
assert_eq!(viewer.len(), NamedView::ALL.len());
for (view, toward_viewer) in viewer {
let b = view.basis();
validate_plane(&b).unwrap();
assert!(close(b.normal(), toward_viewer), "{view:?}: normal {:?}", b.normal());
assert_eq!(b.origin, Vec3::ZERO);
assert!(b.y.z >= 0.0, "{view:?}: paper y never points down");
if matches!(view, NamedView::IsoFrontRight | NamedView::IsoFrontLeft | NamedView::IsoBackRight | NamedView::IsoBackLeft) {
assert!(b.x.z.abs() < EPS, "{view:?}: paper x is horizontal");
}
}
// The principal views' axes are exact, no rounding from a camera, and each lies on its paper
// as the front view folded about the edge they share: the top and bottom views keep x along
// +X, the back and side views keep y along +Z.
let minus = |v: Vec3| Vec3::ZERO - v;
let principal = [
(NamedView::Front, Vec3::X, Vec3::Z),
(NamedView::Back, minus(Vec3::X), Vec3::Z),
(NamedView::Top, Vec3::X, Vec3::Y),
(NamedView::Bottom, Vec3::X, minus(Vec3::Y)),
(NamedView::Left, minus(Vec3::Y), Vec3::Z),
(NamedView::Right, Vec3::Y, Vec3::Z),
];
for (view, x, y) in principal {
assert_eq!((view.basis().x, view.basis().y), (x, y), "{view:?}");
}
// The sketch plane XZ is not the front view: its y is -Z, a front view's is +Z.
assert_eq!(Plane::XZ.y, Vec3::new(0.0, 0.0, -1.0));
assert_eq!(NamedView::Front.basis().y, Vec3::Z);
// No axis has a -0, so a view writes out as `[-1.0, 0.0, 0.0]`, never `[-1.0, -0.0, -0.0]`.
let negative_zero = |v: Vec3| [v.x, v.y, v.z].iter().any(|c| *c == 0.0 && c.is_sign_negative());
assert!(NamedView::ALL.iter().all(|v| !negative_zero(v.basis().x) && !negative_zero(v.basis().y)));
}
/// A view from a direction and an up gives the named views' bases (the principal ones exactly),
/// keeps paper y in the plane of the up, and refuses a direction or up it can't make a view from.
#[test]
fn a_view_from_a_direction_and_up_matches_the_named_views() {
for view in NamedView::ALL {
let named = view.basis();
// Looking down, up is +Y (the back at the top); looking up, -Y (the front at the top).
let up = match view {
NamedView::Top => Vec3::Y,
NamedView::Bottom => Vec3::new(0.0, -1.0, 0.0),
_ => Vec3::Z,
};
let built = basis_looking(-named.normal(), up, Vec3::ZERO).unwrap();
assert!(close(built.x, named.x) && close(built.y, named.y), "{view:?}: {built:?}");
}
assert_eq!(basis_looking(Vec3::Y, Vec3::Z, Vec3::ZERO).unwrap(), NamedView::Front.basis(), "exact");
let leaning = basis_looking(Vec3::Y, Vec3::new(0.0, 0.1, 1.0), Vec3::new(1.0, 2.0, 3.0)).unwrap();
assert_eq!((leaning.x, leaning.y, leaning.origin), (Vec3::X, Vec3::Z, Vec3::new(1.0, 2.0, 3.0)));
let refused = [
(Vec3::ZERO, Vec3::Z, Vec3::ZERO),
(Vec3::new(f64::NAN, 1.0, 0.0), Vec3::Z, Vec3::ZERO),
(Vec3::Y, Vec3::ZERO, Vec3::ZERO),
(Vec3::Y, Vec3::new(0.0, f64::INFINITY, 0.0), Vec3::ZERO),
(Vec3::new(0.0, 0.0, -1.0), Vec3::Z, Vec3::ZERO),
(Vec3::Z, Vec3::new(1e-12, 0.0, 1.0), Vec3::ZERO),
(Vec3::Y, Vec3::Z, Vec3::new(f64::NAN, 0.0, 0.0)),
(Vec3::Y, Vec3::Z, Vec3::new(0.0, 1e13, 0.0)),
];
for (direction, up, origin) in refused {
assert!(basis_looking(direction, up, origin).is_err(), "{direction:?} {up:?} {origin:?}");
}
// A bad origin is named as such, not as a bad basis.
for origin in [Vec3::new(f64::NAN, 0.0, 0.0), Vec3::new(0.0, 1e13, 0.0)] {
assert_eq!(basis_looking(Vec3::Y, Vec3::Z, origin), Err(Invalid("a view's origin is finite and within 1e12 mm of the world's")));
}
assert_eq!(serde_json::from_value::<NamedView>(serde_json::json!("iso")).unwrap(), NamedView::IsoFrontRight);
assert_eq!(serde_json::to_value(NamedView::IsoFrontRight).unwrap(), serde_json::json!("iso_front_right"));
}
/// An up just off the view direction still gives a square basis: its normal is the direction back
/// at the viewer to rounding, where the cross product alone would tilt it by up to 9e-9 rad.
#[test]
fn an_up_just_off_the_direction_still_looks_along_it() {
for (direction, off) in [(Vec3::new(0.3, -0.7, 0.2), 1.0001e-9), (Vec3::new(1.0, 2.0, 3.0), 1.2e-9), (Vec3::Y, 1.0001e-9)] {
let d = direction.normalized().unwrap();
let aside = d.cross(Vec3::Z).normalized().unwrap();
let basis = basis_looking(direction, d + aside * off, Vec3::ZERO).unwrap();
let tilt = (basis.normal() + d).len();
assert!(tilt < 1e-14 && basis.x.dot(d).abs() < 1e-15, "{direction:?}: tilted {tilt:e}");
}
}
/// A 50 mm cube is a 50 mm square in every principal view at 1:1, and half that at 1:2. In an
/// orthographic isometric view each edge reads 50·√(2/3), and the outline is 50·√2 wide.
#[test]
fn a_50_mm_cube_measures_as_the_views_say() {
for view in [NamedView::Front, NamedView::Back, NamedView::Top, NamedView::Bottom, NamedView::Left, NamedView::Right] {
let size = extents(&full_size(view), &cube());
assert!((size - Vec2::new(50.0, 50.0)).len() < EPS, "{view:?}: {size:?}");
let half = ViewFrame::new(view.basis(), "1:2".parse().unwrap(), Vec2::ZERO, Vec2::ZERO).unwrap();
assert!((extents(&half, &cube()) - Vec2::new(25.0, 25.0)).len() < EPS, "{view:?} at 1:2");
}
// Orthographic isometric: a vertical edge reads 50·√(2/3), and the outline is 50·√2 wide.
let iso = full_size(NamedView::IsoFrontRight);
let edge = iso.to_paper(Vec3::new(0.0, 0.0, 50.0)) - iso.to_paper(Vec3::ZERO);
assert!((edge.len() - 50.0 * (2.0f64 / 3.0).sqrt()).abs() < EPS, "{edge:?}");
assert!(edge.x.abs() < EPS, "a vertical edge stays vertical on paper");
let size = extents(&iso, &cube());
assert!((size.x - 50.0 * 2f64.sqrt()).abs() < EPS, "{size:?}");
}
/// The view's centre lands on its position, and the map scales about it.
#[test]
fn the_views_centre_lands_on_its_position() {
let front = NamedView::Front.basis();
let frame = ViewFrame::new(front, "2:1".parse().unwrap(), Vec2::new(50.0, 10.0), Vec2::new(210.0, 148.5)).unwrap();
assert!((frame.to_paper(Vec3::new(50.0, 0.0, 10.0)) - Vec2::new(210.0, 148.5)).len() < EPS);
assert!((frame.to_paper(Vec3::new(0.0, 7.0, 0.0)) - Vec2::new(110.0, 128.5)).len() < EPS, "depth doesn't move a point");
// W07's example: a 100 x 20 front view on A3 at 2:1, centred: boundary 110..310 x 128.5..168.5.
assert!((frame.to_paper(Vec3::new(100.0, 0.0, 20.0)) - Vec2::new(310.0, 168.5)).len() < EPS);
let boundary = frame.boundary(&Aabb2 { min: Vec2::ZERO, max: Vec2::new(100.0, 20.0) }).unwrap();
assert!(boundary.min.dist(Vec2::new(110.0, 128.5)) < EPS && boundary.max.dist(Vec2::new(310.0, 168.5)) < EPS, "{boundary:?}");
// A box that is inside out, or not finite on paper, has no boundary: never one that drops a
// corner. 1e308 is finite in the plane, but not at 2:1.
let box_of = |x0: f64, y0: f64, x1: f64, y1: f64| Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
let refused = [
Aabb2::EMPTY,
box_of(10.0, 0.0, 0.0, 10.0),
box_of(f64::NAN, 0.0, 10.0, 10.0),
box_of(0.0, 0.0, f64::INFINITY, 10.0),
box_of(0.0, 0.0, 1e308, 10.0),
];
for model in refused {
assert_eq!(frame.boundary(&model), Err(Invalid("a view's box is not inside out and is finite on paper")), "{model:?}");
}
assert!(frame.boundary(&box_of(5.0, 5.0, 5.0, 5.0)).is_ok_and(|b| b.min == b.max), "a point's box is a point");
let line = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(100.0, 20.0) };
assert_eq!(frame.seg_to_paper(&line), Seg2::Line { a: Vec2::new(110.0, 128.5), b: Vec2::new(310.0, 168.5) });
let bounded = CurveAccuracy::Bounded { max_deviation_mm: 0.01 };
assert_eq!(frame.accuracy_on_paper(bounded), CurveAccuracy::Bounded { max_deviation_mm: 0.02 });
assert_eq!(frame.accuracy_on_paper(CurveAccuracy::Analytic), CurveAccuracy::Analytic);
}
/// A scale is read and written as paper:model, kept as written, within 1:100000 to 1000:1.
#[test]
fn a_scale_reads_and_writes_as_paper_to_model() {
for (text, factor) in [("1:1", 1.0), ("1:2", 0.5), ("2:1", 2.0), ("1:2.5", 0.4), ("1:100000", 1e-5), ("1000:1", 1000.0), ("3:7", 3.0 / 7.0)] {
let s: Scale = text.parse().unwrap();
assert_eq!((s.to_string(), s.factor()), (text.to_string(), factor), "{text}");
assert_eq!(serde_json::to_value(s).unwrap(), serde_json::json!(text));
assert_eq!(serde_json::from_value::<Scale>(serde_json::json!(text)).unwrap(), s);
}
let not_scales = [
"", "1", "1:", ":2", "1:2:3", "0:1", "1:0", "-1:2", "1:-2", "1:100001", "1001:1", "1/2", " 1:2", "1:2 ", "1e3:1", "NaN:1", "inf:1", "1..5:1",
"1:2.", "١:٢",
];
for text in not_scales {
assert!(text.parse::<Scale>().is_err(), "{text:?}");
}
assert!(serde_json::from_value::<Scale>(serde_json::json!(0.5)).is_err(), "a bare number is not a scale");
for (paper, model) in [(0.0, 1.0), (1.0, 0.0), (f64::NAN, 1.0), (1.0, f64::INFINITY), (1.0, 1e300), (1e-300, 1.0)] {
assert!(Scale::new(paper, model).is_err(), "{paper}:{model}");
}
// Each side is capped too, or 1e300:1e300 (1:1) would write 603 characters.
for (paper, model) in [(1e300, 1e300), (1e-300, 1e-300), (1.000001e6, 1.000001e6), (0.9e-6, 0.9e-6)] {
assert!(Scale::new(paper, model).is_err(), "{paper}:{model}");
}
assert!("1000001:1000001".parse::<Scale>().is_err() && "0.0000009:0.0000009".parse::<Scale>().is_err());
// The longest a scale writes: its widest sides, about 50 characters.
for (paper, model) in [(1e6, 1e6), (MIN_PART, MIN_PART), (1.2345678901234567e-6, 1.2345678901234567e-6), (999999.9999999999, 999999.9999999999)] {
let text = Scale::new(paper, model).unwrap().to_string();
assert!(text.len() <= 50, "{text}");
assert_eq!(text.parse::<Scale>().unwrap(), Scale::new(paper, model).unwrap(), "{text}");
}
}
/// The fitted scale is the largest ISO 5455 preset at which the view fits: W07's 100 x 20 front
/// view fits an A3 drawing area at 2:1 (5:1 would be 500 mm wide), exactly fitting counts, and a
/// view too large for every preset gets none.
#[test]
fn the_fitted_scale_is_the_largest_preset_that_fits() {
let a3 = Vec2::new(400.0, 277.0);
assert_eq!(Scale::fit(Vec2::new(100.0, 20.0), a3).map(|s| s.to_string()), Some("2:1".into()));
assert_eq!(Scale::fit(Vec2::new(400.0, 10.0), a3).map(|s| s.to_string()), Some("1:1".into()), "an exact fit counts");
assert_eq!(Scale::fit(Vec2::new(400.1, 10.0), a3).map(|s| s.to_string()), Some("1:2".into()));
assert_eq!(Scale::fit(Vec2::new(1.0, 1.0), a3).map(|s| s.to_string()), Some("50:1".into()));
assert_eq!(Scale::fit(Vec2::new(4e6, 1.0), a3).map(|s| s.to_string()), Some("1:10000".into()), "ISO 5455's smallest");
assert_eq!(Scale::fit(Vec2::new(5e6, 1.0), a3), None);
// An exact fit at a reduction counts though its factor rounds: 1675.75 * 0.2 is 335.15000000000003.
let room = Vec2::new(335.15, 1000.0);
assert_eq!(Scale::fit(Vec2::new(1675.75, 1.0), room).map(|s| s.to_string()), Some("1:5".into()));
// The presets run largest first, each a 1, 2 or 5 step.
assert!(PRESETS.windows(2).all(|w| w[0].factor() > w[1].factor()));
// A size of 0 sets no limit that way: a line fits by its length, a point at the largest preset.
assert_eq!(Scale::fit(Vec2::new(0.0, 20.0), a3).map(|s| s.to_string()), Some("10:1".into()));
assert_eq!(Scale::fit(Vec2::ZERO, a3).map(|s| s.to_string()), Some("50:1".into()));
// A size or room that is negative or not finite fits nothing.
for (size, room) in [
(Vec2::new(-1.0, 1.0), a3),
(Vec2::new(1.0, -0.0001), a3),
(Vec2::new(f64::NAN, 1.0), a3),
(Vec2::new(1.0, f64::INFINITY), a3),
(Vec2::new(1.0, 1.0), Vec2::new(-400.0, 277.0)),
(Vec2::new(1.0, 1.0), Vec2::new(f64::INFINITY, 277.0)),
(Vec2::new(1.0, 1.0), Vec2::new(400.0, f64::NAN)),
] {
assert_eq!(Scale::fit(size, room), None, "{size:?} in {room:?}");
}
}
/// What a frame refuses: a basis that isn't orthonormal or finite, a centre that isn't finite, and
/// a position past the paper's limit.
#[test]
fn a_frame_refuses_hostile_input() {
let one = Scale::new(1.0, 1.0).unwrap();
let front = NamedView::Front.basis();
let bases = [
Plane { x: Vec3::new(2.0, 0.0, 0.0), ..front },
Plane { x: Vec3::Z, ..front },
Plane { origin: Vec3::new(f64::NAN, 0.0, 0.0), ..front },
Plane { y: Vec3::new(0.0, 0.0, f64::INFINITY), ..front },
];
for basis in bases {
assert!(ViewFrame::new(basis, one, Vec2::ZERO, Vec2::ZERO).is_err(), "{basis:?}");
}
for centre in [Vec2::new(f64::NAN, 0.0), Vec2::new(0.0, f64::INFINITY), Vec2::new(1e13, 0.0)] {
assert!(ViewFrame::new(front, one, centre, Vec2::ZERO).is_err(), "{centre:?}");
}
for position in [Vec2::new(5000.001, 0.0), Vec2::new(0.0, -5000.001), Vec2::new(f64::NAN, 0.0)] {
assert!(ViewFrame::new(front, one, Vec2::ZERO, position).is_err(), "{position:?}");
}
assert!(ViewFrame::new(front, one, Vec2::ZERO, Vec2::new(5000.0, -5000.0)).is_ok());
}
+371
View File
@@ -0,0 +1,371 @@
//! Evaluation of portable B-rep curves: lines, circle and ellipse arcs, polynomial and rational
//! splines (fitted intersection records are ordinary splines with a stated fitting tolerance).
use super::HlrError;
use crate::Vec3;
use crate::brep::{Curve, Frame};
use crate::section::CurveAccuracy;
#[derive(Clone, Debug)]
pub(crate) enum C3 {
Line {
a: Vec3,
b: Vec3,
},
/// origin + x*rx*cos(t) + y*ry*sin(t), t in `range`.
Arc {
o: Vec3,
x: Vec3,
y: Vec3,
r: [f64; 2],
range: [f64; 2],
},
/// Homogeneous control points `[x*w, y*w, z*w, w]` (w = 1 for polynomial splines), and the
/// same curve as Bézier pieces (each piece's parameter span and control points; empty when
/// it could not be split).
Spline {
degree: usize,
knots: Vec<f64>,
points: Vec<[f64; 4]>,
pieces: Vec<([f64; 2], Vec<[f64; 4]>)>,
},
Polyline(Vec<Vec3>),
}
pub(crate) fn v3(p: [f64; 3]) -> Vec3 {
Vec3::new(p[0], p[1], p[2])
}
pub(crate) fn frame_axes(f: &Frame) -> (Vec3, Vec3, Vec3) {
let (x, y) = (v3(f.x_axis), v3(f.y_axis));
(v3(f.origin), x, y)
}
impl C3 {
pub fn new(c: &Curve) -> Result<(C3, CurveAccuracy), HlrError> {
Ok(match c {
Curve::Line { start, end } => (C3::Line { a: v3(*start), b: v3(*end) }, CurveAccuracy::Analytic),
Curve::CircleArc { frame, radius, angle } => {
let (o, x, y) = frame_axes(frame);
(C3::Arc { o, x, y, r: [*radius, *radius], range: *angle }, CurveAccuracy::Analytic)
}
Curve::EllipseArc { frame, radii, angle } => {
let (o, x, y) = frame_axes(frame);
(C3::Arc { o, x, y, r: *radii, range: *angle }, CurveAccuracy::Analytic)
}
Curve::Polyline { points } => (C3::Polyline(points.iter().copied().map(v3).collect()), CurveAccuracy::Analytic),
Curve::Spline { knots, points } => (spline(knots, points.iter().map(|p| [p[0], p[1], p[2], 1.]).collect())?, CurveAccuracy::Analytic),
Curve::RationalSpline { knots, homogeneous } => (spline(knots, homogeneous.clone())?, CurveAccuracy::Analytic),
Curve::Intersection { knots, points, fitting_tolerance, .. } => (
spline(knots, points.iter().map(|p| [p[0], p[1], p[2], 1.]).collect())?,
CurveAccuracy::Bounded { max_deviation_mm: *fitting_tolerance },
),
// Procedural: the curve is traced on demand from its leader, which carries no bound.
Curve::SurfaceIntersection { .. } => return Err(HlrError::Unsupported("surface intersection curve")),
})
}
pub fn domain(&self) -> [f64; 2] {
match self {
C3::Line { .. } => [0., 1.],
C3::Arc { range, .. } => *range,
C3::Spline { degree, knots, points, .. } => [knots.get(*degree).copied().unwrap_or(0.), knots.get(points.len()).copied().unwrap_or(1.)],
C3::Polyline(p) => [0., p.len().saturating_sub(1) as f64],
}
}
pub fn at(&self, t: f64) -> Vec3 {
match self {
C3::Line { a, b } => *a + (*b - *a) * t,
C3::Arc { o, x, y, r, .. } => *o + *x * (r[0] * t.cos()) + *y * (r[1] * t.sin()),
C3::Spline { degree, knots, points, .. } => de_boor(*degree, knots, points, t),
C3::Polyline(p) => {
let n = p.len();
if n == 0 {
return Vec3::ZERO;
}
let t = t.clamp(0., (n - 1) as f64);
let i = (t.floor() as usize).min(n.saturating_sub(2));
match (p.get(i), p.get(i + 1)) {
(Some(a), Some(b)) => a.lerp(*b, t - i as f64),
(Some(a), None) => *a,
_ => Vec3::ZERO,
}
}
}
}
/// First derivative by a central difference scaled to the domain.
pub fn d1(&self, t: f64) -> Vec3 {
match self {
C3::Line { a, b } => *b - *a,
C3::Arc { x, y, r, .. } => *x * (-r[0] * t.sin()) + *y * (r[1] * t.cos()),
_ => {
let [t0, t1] = self.domain();
let h = (t1 - t0) * 1e-6;
let (a, b) = ((t - h).max(t0), (t + h).min(t1));
if b - a <= 0. { Vec3::ZERO } else { (self.at(b) - self.at(a)) / (b - a) }
}
}
}
/// Parameters that split the curve into pieces each turning at most `max_turn` radians or
/// spanning at most a quarter of the domain, plus all interior knots.
pub fn breaks(&self) -> Vec<f64> {
let [t0, t1] = self.domain();
let mut out = vec![t0, t1];
match self {
C3::Spline { knots, .. } => out.extend(knots.iter().copied().filter(|k| *k > t0 && *k < t1)),
C3::Polyline(p) => out.extend((1..p.len().saturating_sub(1)).map(|i| i as f64)),
C3::Arc { .. } => {
let n = ((t1 - t0) / (std::f64::consts::PI / 8.)).ceil().max(1.) as usize;
out.extend((1..n).map(|i| t0 + (t1 - t0) * i as f64 / n as f64));
}
C3::Line { .. } => {}
}
out.sort_by(f64::total_cmp);
// Only equal knots merge: a span narrower than rounding can still carry a whole arc.
out.dedup();
out
}
}
fn spline(knots: &[f64], points: Vec<[f64; 4]>) -> Result<C3, HlrError> {
let degree = knots.len().checked_sub(points.len() + 1).ok_or(HlrError::Unsupported("spline knot count"))?;
if degree == 0 || degree > 16 || points.is_empty() {
return Err(HlrError::Unsupported("spline degree"));
}
let pieces = bezier_pieces(degree, knots, &points);
Ok(C3::Spline { degree, knots: knots.to_vec(), points, pieces })
}
/// A spline split into Bézier pieces: clamped, then every inner knot raised to the degree
/// (NURBS Book §5.2); empty when that fails.
fn bezier_pieces(p: usize, knots: &[f64], points: &[[f64; 4]]) -> Vec<([f64; 2], Vec<[f64; 4]>)> {
use crate::bspline::{HPoint, clamp_row, insert_knot};
let row: Vec<HPoint> = points.iter().map(|h| HPoint { x: h[0], y: h[1], z: h[2], w: h[3] }).collect();
let Ok((mut k, mut c)) = clamp_row(p, knots, &row) else { return Vec::new() };
let (Some(&lo), Some(&hi)) = (k.get(p), k.get(c.len())) else { return Vec::new() };
let mut inner: Vec<f64> = k.iter().copied().filter(|x| *x > lo && *x < hi).collect();
inner.dedup();
for u in &inner {
let mut guard = 0;
while k.iter().filter(|x| *x == u).count() < p {
guard += 1;
let Ok((nk, nc)) = insert_knot(p, &k, &c, *u) else { return Vec::new() };
if guard > p + 1 {
return Vec::new();
}
(k, c) = (nk, nc);
}
}
let breaks: Vec<f64> = std::iter::once(lo).chain(inner).chain(std::iter::once(hi)).collect();
let mut out = Vec::with_capacity(breaks.len());
for (j, w) in breaks.windows(2).enumerate() {
let (Some(&s0), Some(&s1)) = (w.first(), w.get(1)) else { return Vec::new() };
let Some(ctrl) = c.get(j * p..=j * p + p) else { return Vec::new() };
out.push(([s0, s1], ctrl.iter().map(|h| [h.x, h.y, h.z, h.w]).collect()));
}
out
}
/// The Bernstein coefficients of the product of two polynomials given by theirs.
fn bernstein_product(a: &[f64], b: &[f64]) -> Vec<f64> {
use crate::bspline::binomial;
let (m, n) = (a.len().saturating_sub(1), b.len().saturating_sub(1));
(0..=m + n)
.map(|k| {
let terms = (k.saturating_sub(n)..=k.min(m))
.map(|i| binomial(m, i) * binomial(n, k - i) * a.get(i).copied().unwrap_or(0.) * b.get(k - i).copied().unwrap_or(0.));
terms.sum::<f64>() / binomial(m + n, k)
})
.collect()
}
/// The sign a polynomial in Bernstein form keeps over its interval: +1, -1, 0 when it is zero
/// (to rounding) all along, `None` when that cannot be certified. All coefficients of one sign
/// mean the polynomial has it (their hull holds its graph); otherwise the interval is halved.
fn definite_sign(c: &[f64], depth: u32) -> Option<f64> {
let scale = c.iter().fold(0f64, |m, x| m.max(x.abs()));
let eps = 1e-12 * scale;
if c.iter().all(|x| x.abs() <= eps) {
return Some(0.);
}
if c.iter().all(|x| *x >= -eps) {
return Some(1.);
}
if c.iter().all(|x| *x <= eps) {
return Some(-1.);
}
if depth >= 16 {
return None;
}
// de Casteljau at the middle.
let (mut left, mut right, mut pts) = (Vec::with_capacity(c.len()), Vec::with_capacity(c.len()), c.to_vec());
while let (Some(&f), Some(&l)) = (pts.first(), pts.last()) {
left.push(f);
right.push(l);
pts = pts.windows(2).map(|w| 0.5 * (w.first().copied().unwrap_or(0.) + w.get(1).copied().unwrap_or(0.))).collect();
}
right.reverse();
match (definite_sign(&left, depth + 1)?, definite_sign(&right, depth + 1)?) {
(a, b) if a == b || b == 0. => Some(a),
(0., b) => Some(b),
_ => None,
}
}
impl C3 {
/// The way a spline's part `r` turns about the centre `o` in the plane of `u` and `v`:
/// +1 or -1 when its angular speed keeps that sign all along (certified on each Bézier piece
/// from the Bernstein coefficients of `X Y' - Y X'`, which has the speed's sign as the
/// weights are positive), 0 when it does not turn, `None` when it turns back or this cannot
/// be certified.
pub fn turn_sign(&self, r: [f64; 2], o: Vec3, u: Vec3, v: Vec3) -> Option<f64> {
use crate::bspline::{HPoint, bezier_segment};
let C3::Spline { pieces, .. } = self else { return None };
let mut sign = 0f64;
for (span, ctrl) in pieces {
let (a, b) = (r[0].max(span[0]), r[1].min(span[1]));
let len = span[1] - span[0];
if !(b > a && len > 0.) {
continue;
}
let (fa, fb) = (((a - span[0]) / len).clamp(0., 1.), ((b - span[0]) / len).clamp(0., 1.));
if !(fb > fa) {
continue;
}
let h: Vec<HPoint> = ctrl.iter().map(|q| HPoint { x: q[0], y: q[1], z: q[2], w: q[3] }).collect();
let seg = bezier_segment(&h, fa, fb).ok()?;
if seg.iter().any(|q| !(q.w > 0.)) {
return None;
}
let rel = |q: &HPoint| Vec3::new(q.x, q.y, q.z) - o * q.w;
let x: Vec<f64> = seg.iter().map(|q| rel(q).dot(u)).collect();
let y: Vec<f64> = seg.iter().map(|q| rel(q).dot(v)).collect();
let p = x.len().saturating_sub(1);
if p == 0 {
return None;
}
let d = |c: &[f64]| {
c.windows(2).map(|w| p as f64 * (w.get(1).copied().unwrap_or(0.) - w.first().copied().unwrap_or(0.))).collect::<Vec<f64>>()
};
let n: Vec<f64> = bernstein_product(&x, &d(&y)).iter().zip(bernstein_product(&y, &d(&x))).map(|(a, b)| a - b).collect();
let s = definite_sign(&n, 0)?;
if s != 0. {
if sign != 0. && s != sign {
return None;
}
sign = s;
}
}
Some(sign)
}
/// Points whose hull holds the spline's part `r`: the projected control points of its
/// Bézier pieces cut to `r` (de Casteljau). They close in on the curve as `r` shrinks.
/// `None` for other curves, or when a weight is not positive.
pub fn hull(&self, r: [f64; 2]) -> Option<Vec<Vec3>> {
use crate::bspline::{HPoint, bezier_segment};
let C3::Spline { pieces, .. } = self else { return None };
let mut out = Vec::new();
for (span, ctrl) in pieces {
let (a, b) = (r[0].max(span[0]), r[1].min(span[1]));
let len = span[1] - span[0];
if !(b > a && len > 0.) {
continue;
}
let (fa, fb) = (((a - span[0]) / len).clamp(0., 1.), ((b - span[0]) / len).clamp(0., 1.));
if !(fb > fa) {
continue;
}
let h: Vec<HPoint> = ctrl.iter().map(|q| HPoint { x: q[0], y: q[1], z: q[2], w: q[3] }).collect();
for q in bezier_segment(&h, fa, fb).ok()? {
if !(q.w > 0.) {
return None;
}
out.push(q.project()?);
}
}
(!out.is_empty()).then_some(out)
}
}
/// De Boor evaluation of a (rational) B-spline at `t`, clamped to the active domain.
pub(crate) fn de_boor(p: usize, knots: &[f64], ctrl: &[[f64; 4]], t: f64) -> Vec3 {
let n = ctrl.len();
let (Some(&lo), Some(&hi)) = (knots.get(p), knots.get(n)) else { return Vec3::ZERO };
let t = t.clamp(lo, hi);
// Span k with knots[k] <= t < knots[k+1], last nonempty span at the end.
let mut k = p;
while k + 1 < n && knots.get(k + 1).is_some_and(|u| *u <= t) {
k += 1;
}
let mut d: Vec<[f64; 4]> = (0..=p).map(|j| ctrl.get(j + k - p).copied().unwrap_or([0.; 4])).collect();
for r in 1..=p {
for j in (r..=p).rev() {
let (a, b) = (knots.get(j + k - p).copied().unwrap_or(0.), knots.get(j + 1 + k - r).copied().unwrap_or(0.));
let alpha = if b - a == 0. { 0. } else { (t - a) / (b - a) };
let (prev, cur) = (d.get(j - 1).copied().unwrap_or([0.; 4]), d.get(j).copied().unwrap_or([0.; 4]));
if let Some(slot) = d.get_mut(j) {
for c in 0..4 {
slot[c] = (1. - alpha) * prev[c] + alpha * cur[c];
}
}
}
}
let h = d.get(p).copied().unwrap_or([0.; 4]);
if h[3] == 0. { Vec3::ZERO } else { Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3]) }
}
/// The circle a curve lies on, if it is one: centre, unit normal, radius. Checked at many
/// parameters against `tol` (relative to the radius), so rational circle splines are recognised.
pub(crate) fn as_circle(c: &C3, tol: f64) -> Option<(Vec3, Vec3, f64)> {
match c {
C3::Arc { o, x, y, r, .. } if (r[0] - r[1]).abs() <= tol * r[0] => Some((*o, x.cross(*y).normalized()?, r[0])),
C3::Spline { degree, points, .. } => {
let [t0, t1] = c.domain();
let at = |f: f64| c.at(t0 + (t1 - t0) * f);
let (a, b, m) = (at(0.), at(1.), at(0.5));
// Circumcentre of a, m and a third point (b may equal a for a full circle).
let third = if a.dist(b) < 1e-9 * (1. + a.len()) { at(0.25) } else { b };
let (u, v) = (m - a, third - a);
let n = u.cross(v);
let n2 = n.len2();
if n2 <= 1e-24 {
return None;
}
let centre = a + (v * u.len2() - u * v.len2()).cross(n) * (1. / (2. * n2));
let normal = n.normalized()?;
let r = centre.dist(a);
// On one knot span of a (rational) spline of degree p, f = w^2 (|P - c|^2 - r^2) is
// a polynomial of degree 2p. At 2p + 3 Chebyshev points of the span it is at most
// w_max^2 2 r d (d the largest distance from the circle there), so everywhere at most
// 4 times that (their Lebesgue constant is below 4), and as w >= w_min the distance
// anywhere is at most 4 (w_max / w_min)^2 d: weights near zero cannot hide a swerve.
let weights = points.iter().map(|h| h[3]);
let (w_min, w_max) = weights.fold((f64::MAX, f64::MIN), |(lo, hi), w| (lo.min(w), hi.max(w)));
if !(w_min > 0. && w_max.is_finite()) {
return None;
}
let off = |p: Vec3| (p.dist(centre) - r).abs().max((p - centre).dot(normal).abs());
let n = (2 * degree + 3).max(9);
let mut d = 0f64;
for w in c.breaks().windows(2) {
let (Some(&s0), Some(&s1)) = (w.first(), w.get(1)) else { continue };
let (mid, half) = (0.5 * (s0 + s1), 0.5 * (s1 - s0));
d = d.max(off(c.at(s0))).max(off(c.at(s1)));
for k in 0..n {
d = d.max(off(c.at(mid + half * (std::f64::consts::PI * (2 * k + 1) as f64 / (2 * n) as f64).cos())));
}
}
// Relative to the curve's own size when that is smaller than the radius: a nearly
// straight curve fits a huge circle, whose radius says nothing about the error.
let pts: Vec<Vec3> = points.iter().filter(|h| h[3] != 0.).map(|h| Vec3::new(h[0] / h[3], h[1] / h[3], h[2] / h[3])).collect();
let size = pts.iter().skip(1).fold(pts.first().map(|p| (*p, *p)), |acc, p| acc.map(|(lo, hi)| (lo.min(*p), hi.max(*p))));
let size = size.map_or(r, |(lo, hi)| (hi - lo).len());
(4. * (w_max / w_min).powi(2) * d <= tol * r.min(size)).then_some((centre, normal, r))
}
_ => None,
}
}
File diff suppressed because it is too large Load Diff
+347
View File
@@ -0,0 +1,347 @@
//! Silhouettes of curved faces: the curves where the surface normal is perpendicular to the
//! direction of sight. Recognised cylinders and cones give exact lines and spheres an exact
//! circle; other surfaces are contoured on a chart grid, each point refined onto the curve,
//! and the deviation of the chords from the curve is measured.
use super::curve::C3;
use super::surface::{Analytic, S3};
use super::trim::Trim;
use crate::Vec3;
use crate::section::CurveAccuracy;
const TAU: f64 = std::f64::consts::TAU;
pub(crate) struct Sil {
pub curve: C3,
pub acc: CurveAccuracy,
/// Parameter ranges of `curve` that lie inside the face.
pub ranges: Vec<[f64; 2]>,
}
fn perp(a: Vec3) -> Vec3 {
a.any_perp().normalized().unwrap_or(Vec3::new(1., 0., 0.))
}
/// Axial extent of the faces' boundaries relative to `p` along `a`.
fn extent(parts: &[(&S3, &Trim)], p: Vec3, a: Vec3) -> (f64, f64) {
parts.iter().flat_map(|(_, trim)| trim.chords.iter()).flat_map(|(x, y)| [*x, *y]).fold((f64::MAX, f64::MIN), |(lo, hi), q| {
let t = (q - p).dot(a);
(lo.min(t), hi.max(t))
})
}
/// Silhouettes of a set of faces on one surface (`parts`: each face's chart and trim, all
/// recognised as `an`): traced once and kept where they lie on any of the faces, so a
/// silhouette along a seam between them is one curve.
pub(crate) fn silhouettes(parts: &[(&S3, &Trim)], an: Analytic, d: Vec3, out: &mut Vec<Sil>) {
let Some(&(s, trim)) = parts.first() else { return };
let mut push = |curve: C3, acc: CurveAccuracy| {
// A traced curve's chords stand up to their deviation off the surface.
let slack = match acc {
CurveAccuracy::Sampled { max_sample_deviation_mm: dev } => dev,
_ => 0.,
};
let mut ranges: Vec<[f64; 2]> = parts.iter().flat_map(|(s, trim)| inside_ranges(&curve, s, trim, slack)).collect();
ranges.sort_by(|a, b| a[0].total_cmp(&b[0]));
let [t0, t1] = curve.domain();
let mut merged: Vec<[f64; 2]> = Vec::with_capacity(ranges.len());
for r in ranges {
match merged.last_mut() {
Some(last) if r[0] <= last[1] + 1e-12 * (t1 - t0) => last[1] = last[1].max(r[1]),
_ => merged.push(r),
}
}
if !merged.is_empty() {
out.push(Sil { curve, acc, ranges: merged });
}
};
match an {
Analytic::Plane => {}
Analytic::Cylinder { p, a, r } => {
let Some(m) = a.cross(d).normalized() else { return };
let (t0, t1) = extent(parts, p, a);
if !(t1 > t0) {
return;
}
for side in [1., -1.] {
let base = p + m * (r * side);
push(C3::Line { a: base + a * t0, b: base + a * t1 }, CurveAccuracy::Analytic);
}
}
Analytic::Cone { apex, a, half } => {
let e1 = perp(a);
let e2 = a.cross(e1);
let (da, d1, d2) = (d.dot(a), d.dot(e1), d.dot(e2));
let rho = (d1 * d1 + d2 * d2).sqrt();
if rho <= 1e-12 {
return;
}
let c = half.tan() * da / rho;
if c.abs() > 1. {
return;
}
let phid = d2.atan2(d1);
let (h0, h1) = extent(parts, apex, a);
let (t0, t1) = (h0.max(0.) / half.cos(), h1.max(0.) / half.cos());
if !(t1 > t0) {
return;
}
for sign in [1., -1.] {
let phi = phid + sign * c.acos();
let g = a * half.cos() + (e1 * phi.cos() + e2 * phi.sin()) * half.sin();
push(C3::Line { a: apex + g * t0, b: apex + g * t1 }, CurveAccuracy::Analytic);
}
}
Analytic::Sphere { c, r } => {
let x = perp(d);
let y = d.cross(x);
push(C3::Arc { o: c, x, y, r: [r, r], range: [0., TAU] }, CurveAccuracy::Analytic);
}
Analytic::Torus { .. } | Analytic::Other => {
// Several faces of one torus may each hold part of its silhouette: trace it on the
// whole torus then. One face keeps its own chart (finer along a narrow strip).
let whole = if parts.len() > 1 { S3::from_analytic(&an, [0., 0.], Vec3::ZERO) } else { None };
let s = whole.as_ref().unwrap_or(s);
let traced = if matches!(s, S3::Rev(_)) { s.rev_silhouette(d) } else { contour(s, trim, d) };
for (curve, dev) in traced {
let acc = if dev == 0. && matches!(curve, C3::Arc { .. }) {
CurveAccuracy::Analytic
} else {
CurveAccuracy::Sampled { max_sample_deviation_mm: dev }
};
push(curve, acc);
}
}
}
}
/// How far a face's surface may lie from the surface its silhouette set is traced on: rounding,
/// 1e-13 of the face's boundary size plus its coordinates (faces join a set when their
/// recognised surfaces agree to 1e-13). Only that close does a ray along the set's silhouette
/// leave the face within the own-face margin (1e-6 of the size): a surface `e` away is crossed
/// about `sqrt(2 R e)` along the ray.
pub(crate) fn join_tol(trim: &Trim) -> f64 {
1e-13 * (1. + (trim.hi - trim.lo).len()) + 1e-15 * trim.lo.len().max(trim.hi.len())
}
/// Parameter ranges of `c` whose points are inside the face, transitions found by bisection.
/// `slack` is how far the curve itself may stand off the surface (a traced curve's chords).
pub(crate) fn inside_ranges(c: &C3, s: &S3, trim: &Trim, slack: f64) -> Vec<[f64; 2]> {
let [t0, t1] = c.domain();
let n = 256;
let mut hint = None;
// The face is closed: a silhouette along its boundary (a seam between two faces of one
// surface, which is not drawn as an edge) is inside. Off the patch is outside: the inversion
// clamps to the chart's domain.
let tol = 2. * trim.chord_error + 1e-9 * (1. + (trim.hi - trim.lo).len());
let on_patch = tol + 2. * join_tol(trim) + 2. * slack;
let state = |t: f64, hint: &mut Option<[f64; 2]>| -> bool {
let p = c.at(t);
let uv = s.inverse(p, *hint);
*hint = uv;
let Some(uv) = uv else { return false };
match trim.contains(uv) {
_ if s.point(uv).dist(p) > on_patch => false,
Some(true) => true,
Some(false) => trim.near(p, tol),
None => false,
}
};
// Uniform samples, plus the parameters nearest to the face's boundary points: an in-face
// interval is bounded by boundary crossings, so a narrow one between two uniform samples
// still gets samples on both of its sides.
let mut ts: Vec<f64> = (0..=n).map(|i| t0 + (t1 - t0) * i as f64 / n as f64).collect();
let near = 0.02 * (trim.hi - trim.lo).len();
ts.extend(trim.chords.iter().step_by(4).filter_map(|(q, _)| closest_param(c, *q).filter(|t| c.at(*t).dist(*q) <= near)));
ts.retain(|t| *t >= t0 && *t <= t1);
ts.sort_by(f64::total_cmp);
ts.dedup_by(|a, b| (*a - *b).abs() <= 1e-12 * (t1 - t0));
let mut hints = Vec::with_capacity(ts.len());
let states: Vec<bool> = ts
.iter()
.map(|t| {
let st = state(*t, &mut hint);
hints.push(hint);
st
})
.collect();
let mut out = Vec::new();
let mut start: Option<f64> = None;
for i in 0..ts.len() {
let (Some(&t), Some(&inside)) = (ts.get(i), states.get(i)) else { continue };
let prev = i.checked_sub(1).and_then(|j| states.get(j).copied());
if prev.is_some_and(|p| p != inside) {
let (mut lo, mut hi) = (ts.get(i - 1).copied().unwrap_or(t), t);
let near = hints.get(i).copied().flatten();
for _ in 0..50 {
let mid = 0.5 * (lo + hi);
let mut h = near;
if state(mid, &mut h) == inside {
hi = mid;
} else {
lo = mid;
}
}
let edge = 0.5 * (lo + hi);
if inside {
start = Some(edge);
} else if let Some(s0) = start.take() {
out.push([s0, edge]);
}
} else if i == 0 && inside {
start = Some(t);
}
}
if let Some(s0) = start {
out.push([s0, t1]);
}
out.retain(|r| r[1] - r[0] > 1e-12 * (t1 - t0));
out
}
/// The parameter of the point of `c` nearest to `p` (lines and arcs exactly, polylines at a
/// vertex), when `p` projects inside the curve's domain.
fn closest_param(c: &C3, p: Vec3) -> Option<f64> {
match c {
C3::Line { a, b } => {
let e = *b - *a;
let l2 = e.len2();
(l2 > 0.).then(|| ((p - *a).dot(e) / l2).clamp(0., 1.))
}
C3::Arc { o, x, y, r, range } => {
let w = p - *o;
let t = (w.dot(*y) / r[1]).atan2(w.dot(*x) / r[0]);
// The turn of t that falls in the range, if any.
let k = ((range[0] - t) / TAU).ceil();
let t = t + k * TAU;
(t <= range[1]).then_some(t)
}
// The nearest point on any chord, not just the nearest vertex: a face narrower than
// the chords still gets parameters inside it.
C3::Polyline(pts) => pts
.windows(2)
.enumerate()
.filter_map(|(i, w)| {
let (a, b) = (*w.first()?, *w.get(1)?);
let e = b - a;
let f = if e.len2() > 0. { ((p - a).dot(e) / e.len2()).clamp(0., 1.) } else { 0. };
Some((i as f64 + f, (a + e * f).dist(p)))
})
.min_by(|x, y| x.1.total_cmp(&y.1))
.map(|(t, _)| t),
C3::Spline { .. } => None,
}
}
/// Zero set of `normal . d` on a chart grid over the face's chart box.
fn contour(s: &S3, trim: &Trim, d: Vec3) -> Vec<(C3, f64)> {
let Some((lo, hi)) = trim.uv_bounds() else { return Vec::new() };
let dom = s.domain();
let lo = [lo[0].max(dom[0][0]), lo[1].max(dom[1][0])];
let hi = [hi[0].min(dom[0][1]), hi[1].min(dom[1][1])];
if !(hi[0] > lo[0] && hi[1] > lo[1]) {
return Vec::new();
}
let g = 128usize;
let uv = |i: usize, j: usize| [lo[0] + (hi[0] - lo[0]) * i as f64 / g as f64, lo[1] + (hi[1] - lo[1]) * j as f64 / g as f64];
let f = |p: [f64; 2]| s.normal(p).map_or(f64::NAN, |n| n.dot(d));
let vals: Vec<f64> = (0..=g).flat_map(|j| (0..=g).map(move |i| (i, j))).map(|(i, j)| f(uv(i, j))).collect();
let val = |i: usize, j: usize| vals.get(j * (g + 1) + i).copied().unwrap_or(f64::NAN);
let zero = |a: [f64; 2], b: [f64; 2], fa: f64| -> [f64; 2] {
let (mut lo, mut hi, mut flo) = (a, b, fa);
for _ in 0..50 {
let m = [0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1])];
let fm = f(m);
if fm.is_nan() {
break;
}
if (fm > 0.) == (flo > 0.) {
lo = m;
flo = fm;
} else {
hi = m;
}
}
[0.5 * (lo[0] + hi[0]), 0.5 * (lo[1] + hi[1])]
};
// Edge ids: horizontal (i,j)-(i+1,j) = 2k, vertical (i,j)-(i,j+1) = 2k+1, k = j*(g+1)+i.
let mut points = std::collections::BTreeMap::new();
let mut crossing = |id: usize, a: (usize, usize), b: (usize, usize)| -> Option<usize> {
let (fa, fb) = (val(a.0, a.1), val(b.0, b.1));
if fa.is_nan() || fb.is_nan() || (fa > 0.) == (fb > 0.) {
return None;
}
points.entry(id).or_insert_with(|| zero(uv(a.0, a.1), uv(b.0, b.1), fa));
Some(id)
};
let mut links: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
for j in 0..g {
for i in 0..g {
let k = |i: usize, j: usize| j * (g + 1) + i;
let e = [
crossing(2 * k(i, j), (i, j), (i + 1, j)),
crossing(2 * k(i + 1, j) + 1, (i + 1, j), (i + 1, j + 1)),
crossing(2 * k(i, j + 1), (i, j + 1), (i + 1, j + 1)),
crossing(2 * k(i, j) + 1, (i, j), (i, j + 1)),
];
let found: Vec<usize> = e.iter().flatten().copied().collect();
let pairs: Vec<(usize, usize)> = match found.as_slice() {
[a, b] => vec![(*a, *b)],
[a, b, c, dd] => {
let centre = f([0.5 * (uv(i, j)[0] + uv(i + 1, j)[0]), 0.5 * (uv(i, j)[1] + uv(i, j + 1)[1])]);
if (centre > 0.) == (val(i, j) > 0.) { vec![(*a, *b), (*c, *dd)] } else { vec![(*a, *dd), (*b, *c)] }
}
_ => Vec::new(),
};
for (a, b) in pairs {
links.entry(a).or_default().push(b);
links.entry(b).or_default().push(a);
}
}
}
// Chain the links into polylines.
let mut used = std::collections::BTreeSet::new();
let mut chains = Vec::new();
let starts: Vec<usize> = links.iter().filter(|(_, v)| v.len() == 1).map(|(k, _)| *k).chain(links.keys().copied()).collect();
for s0 in starts {
if used.contains(&s0) {
continue;
}
let mut chain = vec![s0];
used.insert(s0);
let mut cur = s0;
while let Some(next) = links.get(&cur).and_then(|v| v.iter().find(|n| !used.contains(*n)).copied()) {
used.insert(next);
chain.push(next);
cur = next;
}
// Close loops back to their start.
if chain.len() > 2 && links.get(&cur).is_some_and(|v| v.contains(&s0)) {
chain.push(s0);
}
if chain.len() >= 2 {
chains.push(chain);
}
}
let mut out = Vec::new();
for chain in chains {
let uvs: Vec<[f64; 2]> = chain.iter().filter_map(|id| points.get(id).copied()).collect();
let pts: Vec<Vec3> = uvs.iter().map(|p| s.point(*p)).collect();
// Deviation: refine each chord's midpoint onto the curve across the chord.
let mut dev = 0f64;
for (w, q) in uvs.windows(2).zip(pts.windows(2)) {
let (Some(a), Some(b), Some(pa), Some(pb)) = (w.first(), w.get(1), q.first(), q.get(1)) else { continue };
let m = [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])];
let n = [-(b[1] - a[1]), b[0] - a[0]];
let (x, y) = ([m[0] - n[0], m[1] - n[1]], [m[0] + n[0], m[1] + n[1]]);
let (fx, fy) = (f(x), f(y));
if fx.is_nan() || fy.is_nan() || (fx > 0.) == (fy > 0.) {
continue;
}
let exact = s.point(zero(x, y, fx));
dev = dev.max(exact.dist_to_segment(*pa, *pb));
}
out.push((C3::Polyline(pts), dev));
}
out
}
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
//! Face trimming in the surface chart. Boundary loops are sampled from the exact edge curves,
//! unwrapped across periodic directions, and a point is classified by the parity of boundary
//! crossings on a path to a point known to be inside. Seams (an edge used twice by one face)
//! are crossed twice, so they cancel without special cases.
use super::curve::C3;
use super::surface::S3;
use crate::Vec3;
use crate::brep::Face;
#[derive(Clone, Debug)]
pub(crate) struct Trim {
/// Unwrapped chart segments of all loops.
segs: Vec<([f64; 2], [f64; 2])>,
period: [Option<f64>; 2],
/// A point inside the face; `None` when the face covers its whole chart.
inside: Option<[f64; 2]>,
/// 3D boundary chords, for distance-to-boundary margins.
pub chords: Vec<(Vec3, Vec3)>,
/// Largest distance between an exact boundary curve and its chords.
pub chord_error: f64,
pub lo: Vec3,
pub hi: Vec3,
/// Why classification is unavailable, if it is.
pub unsupported: Option<&'static str>,
/// Index for the two-leg parity path q -> (r.u, q.v) -> r.
index: Option<Index>,
/// `chords` on a grid, for boundaries with many of them.
grid: Option<ChordGrid>,
}
/// Boundary chords bucketed on a uniform grid over the boundary's box, so a point is checked
/// against the chords around it, not all of them (a circle alone has `trim_samples` per break).
/// Cells are at least as long as the longest chord, so a chord lies in at most two per axis.
#[derive(Clone, Debug)]
struct ChordGrid {
lo: Vec3,
cell: f64,
n: [usize; 3],
/// (cell, chord) pairs sorted by cell.
entries: Vec<(u32, u32)>,
}
impl ChordGrid {
fn new(chords: &[(Vec3, Vec3)], lo: Vec3, hi: Vec3) -> Option<ChordGrid> {
// A few chords are checked faster one by one than through a grid.
if chords.len() < 64 || u32::try_from(chords.len()).is_err() {
return None;
}
let ext = hi - lo;
let longest = chords.iter().map(|(a, b)| a.dist(*b)).fold(0., f64::max);
let cell = longest.max(ext.len() / 256.).max(ext.x.max(ext.y).max(ext.z) / 512.);
if !(cell > 0. && cell.is_finite()) {
return None;
}
let n = [ext.x, ext.y, ext.z].map(|e| ((e / cell).floor() as usize).saturating_add(1).min(513));
let mut grid = ChordGrid { lo, cell, n, entries: Vec::with_capacity(chords.len() * 2) };
for (i, (a, b)) in (0u32..).zip(chords) {
let (c0, c1) = (grid.cell_of(a.min(*b)), grid.cell_of(a.max(*b)));
for x in c0[0]..=c1[0] {
for y in c0[1]..=c1[1] {
for z in c0[2]..=c1[2] {
grid.entries.push((grid.key([x, y, z]), i));
}
}
}
}
grid.entries.sort_unstable();
Some(grid)
}
fn cell_of(&self, p: Vec3) -> [usize; 3] {
let at = |v: f64, l: f64, n: usize| (((v - l) / self.cell).floor().max(0.) as usize).min(n.saturating_sub(1));
[at(p.x, self.lo.x, self.n[0]), at(p.y, self.lo.y, self.n[1]), at(p.z, self.lo.z, self.n[2])]
}
fn key(&self, c: [usize; 3]) -> u32 {
// At most 513 cells per axis: the key fits in 28 bits.
((c[0] * self.n[1] + c[1]) * self.n[2] + c[2]) as u32
}
/// Whether a chord touching a cell of the box `p` +- `tol` is within `tol` of `p`; `None`
/// when that box covers too many cells to be worth it.
fn near(&self, chords: &[(Vec3, Vec3)], p: Vec3, tol: f64) -> Option<bool> {
// A little wider than `tol`, so rounding in the distance cannot pick a chord whose
// nearest point lies in a cell left out.
let w = tol * (1. + 1e-6);
let t = Vec3::new(w, w, w);
let (c0, c1) = (self.cell_of(p - t), self.cell_of(p + t));
let span = |k: usize| c1[k].saturating_sub(c0[k]).saturating_add(1);
if span(0).saturating_mul(span(1)).saturating_mul(span(2)) > 64 {
return None;
}
for x in c0[0]..=c1[0] {
for y in c0[1]..=c1[1] {
for z in c0[2]..=c1[2] {
let k = self.key([x, y, z]);
let first = self.entries.partition_point(|e| e.0 < k);
let close = self
.entries
.get(first..)
.unwrap_or_default()
.iter()
.take_while(|e| e.0 == k)
.any(|(_, i)| chords.get(*i as usize).is_some_and(|(a, b)| p.dist_to_segment(*a, *b) <= tol));
if close {
return Some(true);
}
}
}
}
Some(false)
}
}
/// Segment copies shifted across periods near the inside point, the sorted crossings of the
/// vertical line u = r.u, and v-buckets of the copies for the horizontal leg.
#[derive(Clone, Debug)]
struct Index {
copies: Vec<([f64; 2], [f64; 2])>,
vcross: Vec<f64>,
buckets: Vec<Vec<u32>>,
v0: f64,
dv: f64,
}
fn spans(x: f64, a: f64, b: f64) -> bool {
(a <= x && x < b) || (b <= x && x < a)
}
fn index(segs: &[([f64; 2], [f64; 2])], period: [Option<f64>; 2], r: [f64; 2]) -> Index {
let shifts = |k: usize| -> Vec<f64> {
let Some(p) = period[k] else { return vec![0.] };
let (lo, hi) = segs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (a, b)| (lo.min(a[k]).min(b[k]), hi.max(a[k]).max(b[k])));
let k0 = ((r[k] - p - hi) / p).floor() as i64;
let k1 = ((r[k] + p - lo) / p).ceil() as i64;
(k0.max(-8)..=k1.min(8)).map(|i| i as f64 * p).collect()
};
let mut copies = Vec::new();
for dx in shifts(0) {
for dy in shifts(1) {
copies.extend(segs.iter().map(|(a, b)| ([a[0] + dx, a[1] + dy], [b[0] + dx, b[1] + dy])));
}
}
let mut vcross: Vec<f64> =
copies.iter().filter(|(a, b)| spans(r[0], a[0], b[0])).map(|(a, b)| a[1] + (b[1] - a[1]) * (r[0] - a[0]) / (b[0] - a[0])).collect();
vcross.sort_by(f64::total_cmp);
let (v0, v1) = copies.iter().fold((f64::MAX, f64::MIN), |(lo, hi), (a, b)| (lo.min(a[1]).min(b[1]), hi.max(a[1]).max(b[1])));
let n = ((copies.len() as f64).sqrt().ceil() as usize).clamp(1, 4096);
let dv = ((v1 - v0) / n as f64).max(1e-300);
let mut buckets = vec![Vec::new(); n];
for (i, (a, b)) in copies.iter().enumerate() {
let k0 = (((a[1].min(b[1]) - v0) / dv).floor().max(0.) as usize).min(n - 1);
let k1 = (((a[1].max(b[1]) - v0) / dv).floor().max(0.) as usize).min(n - 1);
for bucket in buckets.iter_mut().take(k1 + 1).skip(k0) {
bucket.push(i as u32);
}
}
Index { copies, vcross, buckets, v0, dv }
}
fn lift(x: f64, near: f64, p: Option<f64>) -> f64 {
match p {
Some(p) if p > 0. => x + p * ((near - x) / p).round(),
_ => x,
}
}
pub(crate) fn build(face: &Face, edges: &[C3], surface: &S3, per_break: usize) -> Trim {
let period = surface.periods();
let mut segs = Vec::new();
let mut seam = Vec::new();
let mut chords = Vec::new();
let mut chord_error = 0f64;
let (mut lo, mut hi) = (Vec3::new(f64::MAX, f64::MAX, f64::MAX), Vec3::new(f64::MIN, f64::MIN, f64::MIN));
let mut uses = std::collections::BTreeMap::new();
for u in face.loops.iter().flatten() {
*uses.entry(u.edge).or_insert(0usize) += 1;
}
let mut area_u = 0.; // -loop integral of v du
let mut area_v = 0.; // loop integral of u dv
let mut wraps = [false; 2];
let mut unsupported = None;
for lp in &face.loops {
let mut pts: Vec<([f64; 2], bool)> = Vec::new();
let mut hint: Option<[f64; 2]> = None;
for u in lp {
let Some(c) = edges.get(u.edge as usize) else {
unsupported = Some("edge reference");
continue;
};
let is_seam = uses.get(&u.edge).copied().unwrap_or(0) > 1;
let mut ts = Vec::new();
let b = c.breaks();
for w in b.windows(2) {
let (Some(t0), Some(t1)) = (w.first(), w.get(1)) else { continue };
for i in 0..per_break {
ts.push(t0 + (t1 - t0) * i as f64 / per_break as f64);
}
}
if let Some(last) = b.last() {
ts.push(*last);
}
if !u.forward {
ts.reverse();
}
let mut prev3: Option<(f64, Vec3)> = None;
for t in ts {
let p = c.at(t);
lo = lo.min(p);
hi = hi.max(p);
if let Some((tp, q)) = prev3 {
chords.push((q, p));
let m = c.at(0.5 * (t + tp));
chord_error = chord_error.max(m.dist_to_segment(q, p));
}
prev3 = Some((t, p));
let Some(mut uv) = surface.inverse(p, hint) else {
unsupported = Some("boundary point off its surface");
continue;
};
if let Some(h) = hint {
uv = [lift(uv[0], h[0], period[0]), lift(uv[1], h[1], period[1])];
}
hint = Some(uv);
pts.push((uv, is_seam));
}
}
let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied()) else { continue };
// Close the loop at the lifted copy of its start (a wrapping loop ends a period away).
let close = [lift(first.0[0], last.0[0], period[0]), lift(first.0[1], last.0[1], period[1])];
for k in 0..2 {
if (close[k] - first.0[k]).abs() > 1e-9 {
wraps[k] = true;
}
}
pts.push((close, last.1));
for w in pts.windows(2) {
let (Some((a, _)), Some((b, s))) = (w.first(), w.get(1)) else { continue };
area_u -= 0.5 * (a[1] + b[1]) * (b[0] - a[0]);
area_v += 0.5 * (a[0] + b[0]) * (b[1] - a[1]);
segs.push((*a, *b));
seam.push(*s);
}
}
let area = match wraps {
[true, true] => {
unsupported = unsupported.or(Some("loops wrap in both chart directions"));
0.
}
[false, true] => area_v,
_ => area_u,
};
// Loops that wind once around a periodic direction without coming back (a hemisphere's
// equator) close against the chart's own edge, so their area depends on where the chart
// starts: there the face is taken on the loops' left (the B-rep convention), and below the
// side within the surface's domain wins either way.
let net = segs.iter().fold([0f64; 2], |n, (a, b)| [n[0] + b[0] - a[0], n[1] + b[1] - a[1]]);
let open = (0..2).any(|k| period[k].is_some_and(|p| net[k].abs() > 0.5 * p));
let sigma = if open { 1. } else { area.signum() };
let dom = surface.domain();
// Inside point: beside the longest non-seam segment, on the face's side.
let mut inside = None;
let real: Vec<usize> = (0..segs.len()).filter(|i| !seam.get(*i).copied().unwrap_or(true)).collect();
if !real.is_empty() && (open || area != 0.) {
let len = |i: usize| segs.get(i).map_or(0., |(a, b)| ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt());
let best = real.iter().copied().max_by(|a, b| len(*a).total_cmp(&len(*b)));
if let Some((a, b)) = best.and_then(|i| segs.get(i)) {
let m = [0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])];
let l = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
let n = [-(b[1] - a[1]) / l * sigma, (b[0] - a[0]) / l * sigma];
// Stay well inside: a quarter of the distance to any other boundary piece.
let mut room = l;
for (j, (c, e)) in segs.iter().enumerate() {
if Some(j) == best {
continue;
}
for (dx, dy) in shifts(period) {
let (c2, e2) = ([c[0] + dx, c[1] + dy], [e[0] + dx, e[1] + dy]);
room = room.min(point_segment(m, c2, e2));
}
}
let delta = 0.25 * room;
// At most half way to the chart's domain edge, so a face hugging that edge (a cone's
// tip) keeps its point; a loop on the edge itself has no room on the outer side.
let step = |dir: [f64; 2]| {
let mut s = delta;
for (((d, &c), &mk), p) in dom.iter().zip(&dir).zip(&m).zip(&period) {
if p.is_some() {
continue;
}
if c < 0. {
s = s.min(0.5 * (mk - d[0]) / -c);
} else if c > 0. {
s = s.min(0.5 * (d[1] - mk) / c);
}
}
s
};
let flip = [-n[0], -n[1]];
let pick = [(n, step(n)), (flip, step(flip))].into_iter().find(|(_, s)| *s > 0. && s.is_finite());
if let Some((dir, s)) = pick {
inside = Some([m[0] + dir[0] * s, m[1] + dir[1] * s]);
}
}
if inside.is_none() {
unsupported = unsupported.or(Some("no room for an inside reference point"));
}
}
let index = inside.map(|r| index(&segs, period, r));
let grid = ChordGrid::new(&chords, lo, hi);
Trim { segs, period, inside, chords, chord_error, lo, hi, unsupported, index, grid }
}
fn shifts(period: [Option<f64>; 2]) -> Vec<(f64, f64)> {
let ks = |p: Option<f64>| p.map_or(vec![0.], |p| vec![-p, 0., p]);
let mut out = Vec::new();
for dx in ks(period[0]) {
for dy in ks(period[1]) {
out.push((dx, dy));
}
}
out
}
fn point_segment(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (ex, ey) = (b[0] - a[0], b[1] - a[1]);
let l2 = ex * ex + ey * ey;
let t = if l2 > 0. { (((p[0] - a[0]) * ex + (p[1] - a[1]) * ey) / l2).clamp(0., 1.) } else { 0. };
((a[0] + t * ex - p[0]).powi(2) + (a[1] + t * ey - p[1]).powi(2)).sqrt()
}
impl Trim {
/// Whether chart point `uv` lies inside the face; `None` when the trim is unsupported.
/// Parity of boundary crossings on the path q -> (r.u, q.v) -> r to the inside point r.
pub fn contains(&self, uv: [f64; 2]) -> Option<bool> {
if self.unsupported.is_some() {
return None;
}
let (Some(r), Some(ix)) = (self.inside, self.index.as_ref()) else { return Some(true) };
let q = [lift(uv[0], r[0], self.period[0]), lift(uv[1], r[1], self.period[1])];
let mut crossings = 0usize;
// Horizontal leg at v = q.v between q.u and r.u.
let (ulo, uhi) = (q[0].min(r[0]), q[0].max(r[0]));
let k = (((q[1] - ix.v0) / ix.dv).floor().max(0.) as usize).min(ix.buckets.len().saturating_sub(1));
for i in ix.buckets.get(k).map(Vec::as_slice).unwrap_or(&[]) {
let Some((a, b)) = ix.copies.get(*i as usize) else { continue };
if spans(q[1], a[1], b[1]) {
let u = a[0] + (b[0] - a[0]) * (q[1] - a[1]) / (b[1] - a[1]);
if u > ulo && u <= uhi {
crossings += 1;
}
}
}
// Vertical leg at u = r.u between q.v and r.v.
let (vlo, vhi) = (q[1].min(r[1]), q[1].max(r[1]));
let first = ix.vcross.partition_point(|v| *v <= vlo);
let last = ix.vcross.partition_point(|v| *v <= vhi);
crossings += last.saturating_sub(first);
Some(crossings.is_multiple_of(2))
}
/// The chart box of the unwrapped boundary.
pub fn uv_bounds(&self) -> Option<([f64; 2], [f64; 2])> {
let mut it = self.segs.iter().flat_map(|(a, b)| [a, b]);
let first = *it.next()?;
Some(it.fold((first, first), |(lo, hi), p| ([lo[0].min(p[0]), lo[1].min(p[1])], [hi[0].max(p[0]), hi[1].max(p[1])])))
}
/// Whether a 3D point is within `tol` of the sampled boundary: the boundary's box first,
/// then the chords around the point (all of them for a short boundary or a wide `tol`).
pub fn near(&self, p: Vec3, tol: f64) -> bool {
let out = (self.lo - p).max(p - self.hi).max(Vec3::new(0., 0., 0.));
if !(out.len() <= tol) {
return false;
}
match self.grid.as_ref().and_then(|g| g.near(&self.chords, p, tol)) {
Some(close) => close,
None => self.chords.iter().any(|(a, b)| p.dist_to_segment(*a, *b) <= tol),
}
}
}
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -15,21 +15,27 @@ pub mod blend;
pub mod brep;
pub mod bspline;
mod bvh;
pub mod cell;
mod chain;
pub mod compare;
pub mod drawing_sheet;
pub mod drawing_view;
pub mod env;
pub mod fill;
pub mod fill_general;
mod grid;
pub mod hlr;
mod mesh;
pub mod mesh_distance;
pub mod network;
pub mod paper;
mod plane;
mod profile;
pub mod section;
pub mod simplify;
pub mod spline_space;
pub mod tess;
pub mod title_block;
pub mod tspline;
mod unit_vec3;
mod vec;
@@ -42,7 +48,7 @@ pub use chain::{Chain, Closed, Ends, Open, Path, Planar, Ring};
pub use grid::Grid;
pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair};
pub use plane::Plane;
pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart};
pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, Similarity2, bezier, regions_apart};
pub use unit_vec3::UnitVec3;
pub use vec::{Vec2, Vec3};
+222
View File
@@ -0,0 +1,222 @@
//! The paper scene: what a sheet shows, as strokes and fills in sheet millimetres, for the writers
//! that put it in a file (DXF, SVG, PNG, PDF). Plain data with one check, [`Scene::validate`]:
//! nothing here is saved, drawn on screen or made by a command yet. Text, clips, line patterns and
//! styles are later additions.
//!
//! - Coordinates are millimetres from the sheet's lower-left corner, x to the right and y up;
//! angles are radians, anticlockwise. A line's width is its width on paper, centred on its path.
//! - Paint goes on in order: the groups in turn, then the items of each group in turn, each over
//! what is already there.
//! - A stroke has butt caps and miter joins with a miter limit of 4 (a join sharper than about 29°
//! is bevelled). A closed stroke joins at its start instead of having two caps. A fill is
//! painted even-odd, so a hole is a hole whichever way its loop runs.
//! - Each group says which object on the sheet drew it, by an id, and what kind of object that is.
//! The id is opaque here: it names one object within the sheet, and several groups may come from
//! the same object. DXF and PDF put a group on the layer its kind names ([`ObjectKind::layer`]).
//!
//! [`Scene::validate`] checks a scene before any writer reads it:
//! - at most [`MAX_RECORDS`] groups, items, loops and curve pieces in all, counted before any
//! geometry is read;
//! - each side of the sheet above 0 and at most [`MAX_SIDE_MM`];
//! - each piece's numbers through the checks section geometry uses, with every stored coordinate
//! and radius finite and at most 1e5 mm in size, so every point drawn has both coordinates
//! within ±2e5 mm;
//! - a stroke's pieces meeting end to start within [`EPS`], and its ends too when it is closed;
//! every loop of a fill closed the same way;
//! - a line width above 0 and at most [`MAX_LINE_MM`].
//!
//! It does not certify simple loops, holes inside their outer loop or a path that never doubles
//! back. Validation after parsing cannot bound parsing allocations: an encoded-input boundary must
//! also limit bytes and nesting.
//!
//! Writers read a [`Prepared`] scene, which only [`Scene::prepare`] makes, so none reads one that
//! wasn't checked. Clipping will happen there too, once, before any writer (A8).
use serde::{Deserialize, Serialize};
use std::f64::consts::FRAC_PI_2;
use crate::{EPS, Region2, Seg2, Vec2};
/// The widest line a scene may draw, in millimetres.
pub const MAX_LINE_MM: f64 = 10.0;
/// The longest side a sheet may have, in millimetres.
pub const MAX_SIDE_MM: f64 = 5000.0;
/// Groups, items, loops and curve pieces a scene may hold in all.
pub const MAX_RECORDS: usize = 100_000;
/// The largest coordinate and radius either way, in millimetres: twenty times the longest side.
pub const MAX_COORDINATE_MM: f64 = 1e5;
/// One sheet's marks, in paint order.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scene {
/// The sheet's width and height.
pub size_mm: Vec2,
pub groups: Vec<Group>,
}
/// Items one object on the sheet drew, painted in order.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Group {
/// The id of the object that drew these items, opaque here.
pub object: u64,
/// What kind of object it is.
pub kind: ObjectKind,
pub items: Vec<Item>,
}
/// What kind of object drew a group.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ObjectKind {
/// The physical edge of the sheet's paper.
SheetBorder,
/// The outline inside the sheet border that bounds the views and annotations, with the grid
/// and the centring and trimming marks of its profile.
DrawingBorder,
/// The block of fields that identifies the drawing and what its sheet documents.
TitleBlock,
/// The sheet table that presents an assembly's bill of materials.
PartsList,
/// The sheet table that lists the drawing's revisions.
RevisionTable,
}
impl ObjectKind {
/// The layer DXF and PDF put this kind's groups on: its name in capitals, as DXF layers are
/// named.
pub fn layer(self) -> &'static str {
match self {
ObjectKind::SheetBorder => "SHEET_BORDER",
ObjectKind::DrawingBorder => "DRAWING_BORDER",
ObjectKind::TitleBlock => "TITLE_BLOCK",
ObjectKind::PartsList => "PARTS_LIST",
ObjectKind::RevisionTable => "REVISION_TABLE",
}
}
}
/// One mark on the paper.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Item {
/// A line `width_mm` wide along `path`, each piece starting where the one before ends. A
/// closed stroke also ends where it starts.
Stroke { path: Vec<Seg2>, closed: bool, width_mm: f64 },
/// A region painted solid.
Fill { region: Region2 },
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("paper scene: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
struct Budget(usize);
impl Budget {
fn add(&mut self, n: usize) -> Result<()> {
const MESSAGE: &str = "more than 100,000 groups, items, loops and pieces";
self.0 = self.0.checked_add(n).ok_or(Invalid(MESSAGE))?;
require(self.0 <= MAX_RECORDS, MESSAGE)
}
}
fn pieces(segs: &[Seg2]) -> Result<()> {
segs.iter().try_for_each(|s| s.check(MAX_COORDINATE_MM).map_err(Invalid))
}
/// Whether `b` starts where `a` ends.
fn meet(a: &Seg2, b: &Seg2) -> bool {
a.end().dist(b.start()) <= EPS
}
/// A scene that passed [`Scene::validate`]: what every writer reads.
#[derive(Clone, Copy, Debug)]
pub struct Prepared<'a> {
scene: &'a Scene,
}
impl<'a> Prepared<'a> {
pub fn size_mm(&self) -> Vec2 {
self.scene.size_mm
}
/// The groups, in paint order.
pub fn groups(&self) -> &'a [Group] {
&self.scene.groups
}
}
/// The ends of the pieces of at most a quarter turn the arc of `sweep` radians from `start`
/// round `center` is drawn in, in order, the last its own end. For writers that draw an arc from
/// its ends (SVG's `A`): there a half turn's centre is ill-conditioned, and moves by up to 1e-2 mm
/// once its ends are rounded to the nanometre (six decimals of a millimetre), where a quarter
/// turn's stays under 1e-6 mm.
pub fn arc_pieces(center: Vec2, radius: f64, start: f64, sweep: f64) -> impl Iterator<Item = Vec2> {
// A scene's sweeps are at most a whole turn and a little: one to four pieces.
let n = ((sweep.abs() / FRAC_PI_2) - 1e-9).ceil().clamp(1.0, 4.0) as usize;
(1..=n).map(move |k| {
let angle = if k == n { start + sweep } else { start + sweep * (k as f64 / n as f64) };
center + Vec2::from_angle(angle) * radius
})
}
impl Scene {
/// The scene, checked ([`Scene::validate`]), for the writers.
pub fn prepare(&self) -> Result<Prepared<'_>> {
self.validate()?;
Ok(Prepared { scene: self })
}
/// Checks the scene against the limits in the module's notes, counting its records first.
pub fn validate(&self) -> Result<()> {
let mut budget = Budget(0);
budget.add(self.groups.len())?;
for group in &self.groups {
budget.add(group.items.len())?;
for item in &group.items {
match item {
Item::Stroke { path, .. } => budget.add(path.len())?,
Item::Fill { region } => {
for l in region.loops() {
budget.add(1 + l.segs.len())?;
}
}
}
}
}
let Vec2 { x, y } = self.size_mm;
require([x, y].into_iter().all(|side| side > 0.0 && side <= MAX_SIDE_MM), "each side of the sheet is above 0 and at most 5000 mm")?;
for item in self.groups.iter().flat_map(|g| &g.items) {
match item {
Item::Stroke { path, closed, width_mm } => {
require(*width_mm > 0.0 && *width_mm <= MAX_LINE_MM, "a line is above 0 and at most 10 mm wide")?;
require(!path.is_empty(), "a stroke has at least one piece")?;
pieces(path)?;
require(path.iter().zip(path.iter().skip(1)).all(|(a, b)| meet(a, b)), "a stroke's pieces meet end to start")?;
if *closed {
require(path.last().zip(path.first()).is_some_and(|(a, b)| meet(a, b)), "a closed stroke ends where it starts")?;
}
}
Item::Fill { region } => {
for l in region.loops() {
pieces(&l.segs)?;
require(l.is_closed(EPS), "each loop of a fill is closed")?;
}
}
}
}
Ok(())
}
}
#[cfg(test)]
#[path = "paper_tests.rs"]
mod tests;
+305
View File
@@ -0,0 +1,305 @@
use std::f64::consts::{PI, TAU};
use serde_json::json;
use super::*;
use crate::{Loop2, MAX_BEZIER_CTRL};
fn p(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
fn line(a: Vec2, b: Vec2) -> Seg2 {
Seg2::Line { a, b }
}
fn stroke(path: Vec<Seg2>, closed: bool, width_mm: f64) -> Item {
Item::Stroke { path, closed, width_mm }
}
fn fill(outer: Loop2, holes: Vec<Loop2>) -> Item {
Item::Fill { region: Region2 { outer, holes } }
}
/// Anticlockwise from `min`.
fn rectangle(min: Vec2, max: Vec2) -> Loop2 {
Loop2::polygon(&[min, p(max.x, min.y), max, p(min.x, max.y)])
}
/// An A3 landscape sheet with one object's items.
fn sheet(items: Vec<Item>) -> Scene {
Scene { size_mm: p(420.0, 297.0), groups: vec![Group { object: 1, kind: ObjectKind::DrawingBorder, items }] }
}
fn open(path: Vec<Seg2>) -> Scene {
sheet(vec![stroke(path, false, 0.35)])
}
fn closed(path: Vec<Seg2>) -> Scene {
sheet(vec![stroke(path, true, 0.35)])
}
/// ISO 5457's drawing border on an A3 landscape sheet, and the two trimming rectangles at its
/// lower-left corner, by hand.
fn a3() -> Scene {
sheet(vec![
stroke(rectangle(p(20.0, 10.0), p(410.0, 287.0)).segs, true, 0.7),
fill(rectangle(p(0.0, 0.0), p(10.0, 5.0)), vec![]),
fill(rectangle(p(0.0, 0.0), p(5.0, 10.0)), vec![]),
])
}
#[test]
fn an_a3_drawing_border_and_its_trimming_marks_validate_and_round_trip() {
let scene = a3();
scene.validate().unwrap();
let value = serde_json::to_value(&scene).unwrap();
assert_eq!(value["size_mm"], json!([420.0, 297.0]));
let group = &value["groups"][0];
assert_eq!(group["object"], json!(1));
assert_eq!(group["kind"], "drawing_border");
let border = &group["items"][0];
assert_eq!(border["kind"], "stroke");
assert_eq!(border["closed"], true);
assert_eq!(border["width_mm"], 0.7);
assert_eq!(border["path"][0], json!({"type": "line", "a": [20.0, 10.0], "b": [410.0, 10.0]}));
assert_eq!(border["path"][2], json!({"type": "line", "a": [410.0, 287.0], "b": [20.0, 287.0]}));
assert_eq!(border["path"][3]["b"], json!([20.0, 10.0]));
let trim = &group["items"][2];
assert_eq!(trim["kind"], "fill");
assert_eq!(trim["region"]["outer"]["segs"][1], json!({"type": "line", "a": [5.0, 0.0], "b": [5.0, 10.0]}));
assert_eq!(trim["region"]["holes"], json!([]));
let restored: Scene = serde_json::from_value(value).unwrap();
restored.validate().unwrap();
assert_eq!(restored, scene);
}
#[test]
fn each_kind_names_its_layer_in_the_capitals_of_its_id() {
use ObjectKind::*;
for (kind, layer) in [
(SheetBorder, "SHEET_BORDER"),
(DrawingBorder, "DRAWING_BORDER"),
(TitleBlock, "TITLE_BLOCK"),
(PartsList, "PARTS_LIST"),
(RevisionTable, "REVISION_TABLE"),
] {
assert_eq!(kind.layer(), layer);
let id = layer.to_ascii_lowercase();
assert_eq!(serde_json::to_value(kind).unwrap(), json!(id));
assert_eq!(serde_json::from_value::<ObjectKind>(json!(id)).unwrap(), kind);
}
}
#[test]
fn each_side_of_the_sheet_is_above_zero_and_at_most_5000_mm() {
const REFUSED: Invalid = Invalid("each side of the sheet is above 0 and at most 5000 mm");
// Marks may lie off a small sheet: the scene doesn't clip them.
for side in [5000.0, 1e-9, f64::MIN_POSITIVE] {
for size_mm in [p(side, 297.0), p(420.0, side)] {
Scene { size_mm, ..a3() }.validate().unwrap();
}
}
for side in [0.0, -0.0, -1.0, 5000.001, 5000f64.next_up(), f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for size_mm in [p(side, 297.0), p(420.0, side)] {
assert_eq!(Scene { size_mm, ..a3() }.validate(), Err(REFUSED), "{size_mm:?}");
}
}
}
#[test]
fn a_line_is_above_zero_and_at_most_10_mm_wide() {
let border = |width| sheet(vec![stroke(rectangle(p(20.0, 10.0), p(410.0, 287.0)).segs, true, width)]);
for width in [1e-9, 0.7, 10.0] {
border(width).validate().unwrap();
}
for width in [0.0, -0.0, -0.7, 10.000001, 10f64.next_up(), f64::NAN, f64::INFINITY] {
assert_eq!(border(width).validate(), Err(Invalid("a line is above 0 and at most 10 mm wide")), "{width}");
}
}
#[test]
fn coordinates_and_radii_are_finite_and_at_most_1e5_mm() {
const REFUSED: Invalid = Invalid("non-finite or excessive coordinate");
for v in [1e5, -1e5] {
open(vec![line(p(0.0, 0.0), p(v, 0.0))]).validate().unwrap();
open(vec![line(p(0.0, v), p(1.0, 0.0))]).validate().unwrap();
}
for v in [1e5 + 1e-6, -1e5 - 1e-6, 1e5f64.next_up(), f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(open(vec![line(p(0.0, 0.0), p(v, 0.0))]).validate(), Err(REFUSED), "{v}");
assert_eq!(open(vec![line(p(0.0, v), p(1.0, 0.0))]).validate(), Err(REFUSED), "{v}");
assert_eq!(sheet(vec![fill(rectangle(p(0.0, 0.0), p(v, 1.0)), vec![])]).validate(), Err(REFUSED), "{v}");
}
// A radius has the same bound, so a circle's points stay within ±2e5 mm each way.
let circle = |center, radius| vec![Seg2::Arc { center, radius, start: 0.0, sweep: TAU }];
closed(circle(p(1e5, -1e5), 1e5)).validate().unwrap();
for radius in [1e5 + 1e-6, 0.0, -1.0, 1e-13, f64::NAN, f64::INFINITY] {
assert_eq!(closed(circle(p(0.0, 0.0), radius)).validate(), Err(Invalid("arc radius")), "{radius}");
}
assert_eq!(closed(circle(p(1e5 + 1e-6, 0.0), 1.0)).validate(), Err(REFUSED));
}
#[test]
fn every_piece_goes_through_the_section_checks_at_the_papers_bound() {
let arc = |radius, start, sweep| Seg2::Arc { center: p(50.0, 50.0), radius, start, sweep };
let conic = |w| Seg2::Conic { a: p(1.0, 0.0), apex: p(1.0, 1.0), b: p(0.0, 1.0), w };
let one = p(1.0, 1.0);
for (piece, message) in [
(arc(0.0, 0.0, PI), "arc radius"),
(arc(10.0, 1e6 + 1.0, PI), "arc start angle"),
(arc(10.0, 0.0, 0.0), "arc sweep"),
(arc(10.0, 0.0, -TAU - 1e-8), "arc sweep"),
(conic(0.0), "conic weight"),
(conic(1e12 + 1.0), "conic weight"),
(line(one, one), "collapsed curve"),
(Seg2::Cubic { p0: one, p1: one, p2: one, p3: one }, "collapsed curve"),
] {
assert_eq!(open(vec![piece]).validate(), Err(Invalid(message)), "{piece:?}");
}
// A conic's weight is a ratio, not a length: the paper's bound doesn't apply to it.
open(vec![conic(1e12)]).validate().unwrap();
// A fill's loops go through the same checks: a zero-radius arc in a hole.
let hole = Loop2 { segs: vec![arc(0.0, 0.0, TAU)] };
assert_eq!(sheet(vec![fill(rectangle(p(0.0, 0.0), p(100.0, 100.0)), vec![hole])]).validate(), Err(Invalid("arc radius")));
// A Bézier's unused slots are checked at the paper's bound too, as they are serialized.
let mut controls = [Vec2::ZERO; MAX_BEZIER_CTRL];
for (i, q) in controls.iter_mut().enumerate().take(5) {
*q = p(i as f64, (i % 2) as f64);
}
open(vec![Seg2::Bezier { n: 4, p: controls }]).validate().unwrap();
for value in [f64::NAN, f64::INFINITY, 1e5 + 1e-6] {
let mut hidden = controls;
hidden[MAX_BEZIER_CTRL - 1] = p(value, 0.0);
assert_eq!(open(vec![Seg2::Bezier { n: 4, p: hidden }]).validate(), Err(Invalid("non-finite or excessive coordinate")), "{value}");
}
for n in [3, 10] {
assert_eq!(open(vec![Seg2::Bezier { n, p: controls }]).validate(), Err(Invalid("Bezier degree")));
}
}
#[test]
fn a_strokes_pieces_meet_within_1e_9_mm_and_a_closed_one_ends_where_it_starts() {
const APART: Invalid = Invalid("a stroke's pieces meet end to start");
const OPEN_ENDS: Invalid = Invalid("a closed stroke ends where it starts");
// The joins lie at the origin, where a gap of 1e-9 mm is exact.
let corner = |gap| vec![line(p(-10.0, 0.0), p(0.0, 0.0)), line(p(gap, 0.0), p(0.0, 10.0))];
open(corner(1e-9)).validate().unwrap();
for gap in [2e-9, 1e-9f64.next_up(), -2e-9] {
assert_eq!(open(corner(gap)).validate(), Err(APART), "{gap}");
}
let square = |gap| vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(10.0, 10.0)), line(p(10.0, 10.0), p(0.0, gap))];
closed(square(1e-9)).validate().unwrap();
for gap in [2e-9, 1e-9f64.next_up(), 10.0] {
assert_eq!(closed(square(gap)).validate(), Err(OPEN_ENDS), "{gap}");
open(square(gap)).validate().unwrap();
}
}
#[test]
fn stroke_shapes_one_piece_reversed_duplicated_and_doubled_back() {
let a = line(p(0.0, 0.0), p(10.0, 0.0));
let border = rectangle(p(20.0, 10.0), p(410.0, 287.0));
open(vec![a]).validate().unwrap();
assert_eq!(closed(vec![a]).validate(), Err(Invalid("a closed stroke ends where it starts")));
assert_eq!(open(vec![]).validate(), Err(Invalid("a stroke has at least one piece")));
assert_eq!(closed(vec![]).validate(), Err(Invalid("a stroke has at least one piece")));
// A whole circle as one piece, or as two halves.
closed(vec![Seg2::Arc { center: p(100.0, 100.0), radius: 5.0, start: 0.3, sweep: -TAU }]).validate().unwrap();
closed(Loop2::circle(p(100.0, 100.0), 5.0).segs).validate().unwrap();
closed(border.reversed().segs).validate().unwrap();
// A piece twice over doesn't join; out of order neither.
assert_eq!(open(vec![a, a]).validate(), Err(Invalid("a stroke's pieces meet end to start")));
let mut swapped = border.segs.clone();
swapped.swap(1, 2);
assert_eq!(closed(swapped).validate(), Err(Invalid("a stroke's pieces meet end to start")));
// Doubling back joins, so it is accepted: the scene doesn't certify a path.
open(vec![a, a.reversed()]).validate().unwrap();
closed(vec![a, a.reversed()]).validate().unwrap();
// Nothing to draw is a valid scene, and so is an object that drew nothing.
Scene { groups: vec![], ..a3() }.validate().unwrap();
sheet(vec![]).validate().unwrap();
}
#[test]
fn every_loop_of_a_fill_is_closed_within_1e_9_mm() {
const OPEN: Invalid = Invalid("each loop of a fill is closed");
let outer = rectangle(p(0.0, 0.0), p(100.0, 100.0));
// A triangle whose last piece ends `gap` from its start, at the origin.
let triangle = |gap| Loop2 { segs: vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(0.0, 10.0)), line(p(0.0, 10.0), p(0.0, gap))] };
sheet(vec![fill(triangle(1e-9), vec![])]).validate().unwrap();
let hole = |gap: f64| triangle(gap).reversed();
sheet(vec![fill(outer.clone(), vec![hole(1e-9)])]).validate().unwrap();
for gap in [2e-9, 1e-9f64.next_up(), 5.0] {
assert_eq!(sheet(vec![fill(triangle(gap), vec![])]).validate(), Err(OPEN), "{gap}");
assert_eq!(sheet(vec![fill(outer.clone(), vec![hole(gap)])]).validate(), Err(OPEN), "{gap}");
}
assert_eq!(sheet(vec![fill(Loop2::default(), vec![])]).validate(), Err(OPEN));
assert_eq!(sheet(vec![fill(outer.clone(), vec![Loop2::default()])]).validate(), Err(OPEN));
// Even-odd: a hole that runs the same way as its outer loop, and a round hole, are accepted.
sheet(vec![fill(outer.clone(), vec![rectangle(p(10.0, 10.0), p(20.0, 20.0))])]).validate().unwrap();
sheet(vec![fill(outer, vec![Loop2::circle(p(50.0, 50.0), 5.0).reversed()])]).validate().unwrap();
}
#[test]
fn at_most_100_000_records_are_counted_before_any_geometry() {
const REFUSED: Invalid = Invalid("more than 100,000 groups, items, loops and pieces");
let group = |items| Group { object: 7, kind: ObjectKind::PartsList, items };
let scene = |groups| Scene { size_mm: p(420.0, 297.0), groups };
// Groups alone.
scene(vec![group(vec![]); MAX_RECORDS]).validate().unwrap();
assert_eq!(scene(vec![group(vec![]); MAX_RECORDS + 1]).validate(), Err(REFUSED));
// One group and one stroke, then its pieces.
let steps = |n: usize| -> Vec<Seg2> { (0..n).map(|i| line(p(i as f64 * 1e-3, 0.0), p((i + 1) as f64 * 1e-3, 0.0))).collect() };
scene(vec![group(vec![stroke(steps(MAX_RECORDS - 2), false, 0.35)])]).validate().unwrap();
assert_eq!(scene(vec![group(vec![stroke(steps(MAX_RECORDS - 1), false, 0.35)])]).validate(), Err(REFUSED));
// A fill counts each loop as well as its pieces: 2 + (1 + 5) + 49,996 × (1 + 1) = 100,000.
let holes = vec![Loop2 { segs: vec![Seg2::Arc { center: p(200.0, 150.0), radius: 1.0, start: 0.0, sweep: TAU }] }; 49_996];
let polygon = |n: usize| {
let corners: Vec<Vec2> = (0..n).map(|i| p(200.0, 150.0) + Vec2::from_angle(TAU * i as f64 / n as f64) * 100.0).collect();
Loop2::polygon(&corners)
};
scene(vec![group(vec![fill(polygon(5), holes.clone())])]).validate().unwrap();
assert_eq!(scene(vec![group(vec![fill(polygon(6), holes)])]).validate(), Err(REFUSED));
// Counted first: an over-budget scene with a non-finite piece reports the count.
let mut bad = steps(MAX_RECORDS - 1);
bad[0] = line(p(f64::NAN, 0.0), p(1.0, 0.0));
assert_eq!(Scene { size_mm: p(f64::NAN, 0.0), ..scene(vec![group(vec![stroke(bad, false, f64::NAN)])]) }.validate(), Err(REFUSED));
let mut count = Budget(MAX_RECORDS);
assert_eq!(count.add(usize::MAX), Err(REFUSED));
}
#[test]
fn unknown_fields_kinds_and_ids_are_refused_when_read() {
let value = serde_json::to_value(a3()).unwrap();
let read = |v: serde_json::Value| serde_json::from_value::<Scene>(v);
read(value.clone()).unwrap();
for at in ["", "/groups/0", "/groups/0/items/0", "/groups/0/items/1"] {
let mut v = value.clone();
v.pointer_mut(at).unwrap()["layer"] = json!("A");
assert!(read(v).is_err(), "an unknown field at {at:?}");
}
let mut v = value.clone();
v["groups"][0]["items"][0]["kind"] = json!("text");
assert!(read(v).is_err());
for kind in ["frame", "Title Block", "TITLE_BLOCK", "titleblock", ""] {
let mut v = value.clone();
v["groups"][0]["kind"] = json!(kind);
assert!(read(v).is_err(), "{kind:?}");
}
// The id is any u64 and nothing else.
for object in [json!(-1), json!(1.5), json!("1"), json!(null)] {
let mut v = value.clone();
v["groups"][0]["object"] = object;
assert!(read(v).is_err());
}
let mut v = value.clone();
v["groups"][0]["object"] = json!("ID");
let with = |id: &str| serde_json::from_str::<Scene>(&serde_json::to_string(&v).unwrap().replace("\"ID\"", id));
with("1").unwrap();
assert!(with("18446744073709551616").is_err());
v["groups"][0].as_object_mut().unwrap().remove("object");
assert!(read(v).is_err());
let mut v = value;
v["groups"][0]["object"] = json!(u64::MAX);
read(v).unwrap().validate().unwrap();
}
+121
View File
@@ -58,6 +58,42 @@ pub const MAX_BEZIER_CTRL: usize = MAX_BEZIER_DEGREE + 1;
/// continuity order a blend curve can be built for (4 = G4).
pub const MAX_DERIVATIVE_ORDER: usize = 4;
/// A similarity of the plane: a point is scaled by `scale` (above 0) about the origin, turned
/// `angle` radians counter-clockwise, then moved by `shift`. It keeps shapes and the direction
/// curves turn in, so an arc stays an arc ([`Seg2::similar`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Similarity2 {
scale: f64,
angle: f64,
shift: Vec2,
/// The angle's cosine and sine times the scale, worked out once rather than per point.
cos: f64,
sin: f64,
}
impl Similarity2 {
/// `None` unless `scale` is finite and above 0 and `angle` and `shift` are finite.
pub fn new(scale: f64, angle: f64, shift: Vec2) -> Option<Similarity2> {
if !(scale.is_finite() && scale > 0.0 && angle.is_finite() && shift.is_finite()) {
return None;
}
let (sin, cos) = angle.sin_cos();
Some(Similarity2 { scale, angle, shift, cos: cos * scale, sin: sin * scale })
}
pub fn scale(&self) -> f64 {
self.scale
}
pub fn angle(&self) -> f64 {
self.angle
}
pub fn apply(&self, p: Vec2) -> Vec2 {
Vec2::new(self.cos * p.x - self.sin * p.y, self.sin * p.x + self.cos * p.y) + self.shift
}
}
/// A Bézier segment over `pts` (2…[`MAX_BEZIER_CTRL`] points): a line, a cubic or a higher-degree
/// piece. A quadratic becomes a conic of weight 1.
pub fn bezier(pts: &[Vec2]) -> Option<Seg2> {
@@ -76,6 +112,62 @@ pub fn bezier(pts: &[Vec2]) -> Option<Seg2> {
})
}
/// Smallest radius, conic weight and absolute arc sweep [`Seg2::check`] accepts.
const MIN_POSITIVE: f64 = 1e-12;
/// Largest conic weight [`Seg2::check`] accepts.
const MAX_WEIGHT: f64 = 1e12;
/// Largest arc start angle either way, in radians, [`Seg2::check`] accepts.
const MAX_START_ANGLE: f64 = 1e6;
fn require(condition: bool, message: &'static str) -> Result<(), &'static str> {
if condition { Ok(()) } else { Err(message) }
}
/// Every value finite and at most `max_abs` in size: [`Seg2::check`]'s rule for coordinates, which
/// `section` applies to its planes and contacts too.
pub(crate) fn coordinates(values: impl IntoIterator<Item = f64>, max_abs: f64) -> Result<(), &'static str> {
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= max_abs), "non-finite or excessive coordinate")
}
fn controls(points: &[Vec2], max_abs: f64) -> Result<(), &'static str> {
coordinates(points.iter().flat_map(|p| [p.x, p.y]), max_abs)?;
let Some((first, rest)) = points.split_first() else { return Err("empty controls") };
require(rest.iter().any(|p| p != first), "collapsed curve")
}
impl Seg2 {
/// Checks a segment read from outside before anything evaluates it: every stored coordinate
/// finite and at most `max_abs` in size (a Bézier's unused slots too, as they are serialized),
/// a radius in 1e-12..=`max_abs`, an arc's start within ±1e6 radians and its absolute sweep
/// 1e-12 to one turn (with 1e-12 radians of allowance), a conic weight in 1e-12..=1e12, a
/// Bézier degree of 4 to [`MAX_BEZIER_DEGREE`], controls not all at one point, and finite
/// ends. The limits keep evaluation finite; they promise no accuracy. The caller wraps the
/// message in its own error.
pub(crate) fn check(&self, max_abs: f64) -> Result<(), &'static str> {
match *self {
Seg2::Line { a, b } => controls(&[a, b], max_abs)?,
Seg2::Arc { center, radius, start, sweep } => {
coordinates([center.x, center.y], max_abs)?;
require(radius.is_finite() && (MIN_POSITIVE..=max_abs).contains(&radius), "arc radius")?;
require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?;
require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?;
}
Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3], max_abs)?,
Seg2::Conic { a, apex, b, w } => {
controls(&[a, apex, b], max_abs)?;
require(w.is_finite() && (MIN_POSITIVE..=MAX_WEIGHT).contains(&w), "conic weight")?;
}
Seg2::Bezier { n, ref p } => {
require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?;
// The unused slots are serialized too: reject hidden non-finite/excessive values.
coordinates(p.iter().flat_map(|q| [q.x, q.y]), max_abs)?;
controls(p.get(..=usize::from(n)).ok_or("Bezier degree")?, max_abs)?;
}
}
require(self.start().is_finite() && self.end().is_finite(), "non-finite curve endpoint")
}
}
/// de Casteljau: the point at `t` and the two halves' control points.
fn casteljau(c: &[Vec2], t: f64) -> (Vec2, Vec<Vec2>, Vec<Vec2>) {
let mut w = c.to_vec();
@@ -318,6 +410,31 @@ impl Seg2 {
}
}
}
/// The segment with each control point mapped by `f`: exact for any affine map of lines,
/// Béziers and conics. An arc is returned as it is: only a similarity keeps it an arc
/// ([`Seg2::similar`]), and callers that mirror one handle it themselves.
pub fn map_controls(&self, f: impl Fn(Vec2) -> Vec2) -> Seg2 {
match *self {
Seg2::Line { a, b } => Seg2::Line { a: f(a), b: f(b) },
Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: f(p0), p1: f(p1), p2: f(p2), p3: f(p3) },
Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: f(a), apex: f(apex), b: f(b), w },
Seg2::Bezier { n, mut p } => {
p.iter_mut().take(usize::from(n) + 1).for_each(|q| *q = f(*q));
Seg2::Bezier { n, p }
}
arc @ Seg2::Arc { .. } => arc,
}
}
/// The segment moved by the similarity `m`, exactly: an arc's centre maps, its radius scales
/// and its start turns; every other kind maps its control points.
pub fn similar(&self, m: &Similarity2) -> Seg2 {
match *self {
Seg2::Arc { center, radius, start, sweep } => {
Seg2::Arc { center: m.apply(center), radius: radius * m.scale(), start: start + m.angle(), sweep }
}
_ => self.map_controls(|p| m.apply(p)),
}
}
pub fn length(&self) -> f64 {
match *self {
Seg2::Line { a, b } => a.dist(b),
@@ -586,6 +703,10 @@ impl Region2 {
pub fn area(&self) -> f64 {
self.outer.signed_area().abs() - self.holes.iter().map(|h| h.signed_area().abs()).sum::<f64>()
}
/// The outer loop, then the holes.
pub fn loops(&self) -> impl Iterator<Item = &Loop2> + Clone {
std::iter::once(&self.outer).chain(&self.holes)
}
/// A point strictly inside the region (for picking and labels).
pub fn interior_point(&self) -> Vec2 {
let c = self.centroid();
+20 -43
View File
@@ -21,18 +21,15 @@
//! exact or certified measures for conics and freeform curves. Profile values contain geometry
//! only: callers retain the original result's accuracy declarations and local identities.
use std::f64::consts::TAU;
use serde::{Deserialize, Serialize};
use crate::{Loop2, MAX_BEZIER_DEGREE, Plane, Region2, Seg2, Vec2, Vec3};
use crate::profile::coordinates;
use crate::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
/// Aggregate input records/references, and the selected conversion's output items, at most.
/// Input counts curves, wires, regions, contacts, curve uses and hole references together.
pub const MAX_ITEMS: usize = 100_000;
const MAX_NUMBER: f64 = 1e12;
const MIN_POSITIVE: f64 = 1e-12;
const MAX_START_ANGLE: f64 = 1e6;
pub(crate) const MAX_NUMBER: f64 = 1e12;
const FRAME_TOLERANCE: f64 = 1e-8;
/// A section's geometric result, without source identity, freshness or persistence semantics.
@@ -68,6 +65,20 @@ pub enum CurveAccuracy {
Sampled { max_sample_deviation_mm: f64 },
}
impl CurveAccuracy {
/// The same accuracy for the curve scaled by `factor` (above 0): a deviation scales with it,
/// and an analytic curve stays analytic.
pub fn scaled(self, factor: f64) -> CurveAccuracy {
match self {
CurveAccuracy::Analytic => CurveAccuracy::Analytic,
CurveAccuracy::Bounded { max_deviation_mm } => CurveAccuracy::Bounded { max_deviation_mm: max_deviation_mm * factor },
CurveAccuracy::Sampled { max_sample_deviation_mm } => {
CurveAccuracy::Sampled { max_sample_deviation_mm: max_sample_deviation_mm * factor }
}
}
}
}
/// An ordered connected chain. `closed` is explicit, never inferred or repaired.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -111,16 +122,12 @@ impl Budget {
}
}
fn numbers(values: impl IntoIterator<Item = f64>) -> Result<()> {
require(values.into_iter().all(|v| v.is_finite() && v.abs() <= MAX_NUMBER), "non-finite or excessive coordinate")
}
fn point(p: Vec2) -> Result<()> {
numbers([p.x, p.y])
coordinates([p.x, p.y], MAX_NUMBER).map_err(Invalid)
}
fn vector(p: Vec3) -> Result<()> {
numbers([p.x, p.y, p.z])
coordinates([p.x, p.y, p.z], MAX_NUMBER).map_err(Invalid)
}
/// Strictly check an existing plane without re-orthonormalizing it or changing its serde format.
@@ -137,38 +144,8 @@ pub fn validate_plane(plane: &Plane) -> Result<()> {
)
}
fn controls(points: &[Vec2]) -> Result<()> {
for p in points {
point(*p)?;
}
let Some((first, rest)) = points.split_first() else { return Err(Invalid("empty controls")) };
require(rest.iter().any(|p| p != first), "collapsed curve")
}
fn curve(curve: &SectionCurve) -> Result<()> {
match curve.geometry {
Seg2::Line { a, b } => controls(&[a, b])?,
Seg2::Arc { center, radius, start, sweep } => {
point(center)?;
require(radius.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&radius), "arc radius")?;
require(start.is_finite() && start.abs() <= MAX_START_ANGLE, "arc start angle")?;
require(sweep.is_finite() && (MIN_POSITIVE..=TAU + 1e-12).contains(&sweep.abs()), "arc sweep")?;
}
Seg2::Cubic { p0, p1, p2, p3 } => controls(&[p0, p1, p2, p3])?,
Seg2::Conic { a, apex, b, w } => {
controls(&[a, apex, b])?;
require(w.is_finite() && (MIN_POSITIVE..=MAX_NUMBER).contains(&w), "conic weight")?;
}
Seg2::Bezier { n, ref p } => {
require((4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)), "Bezier degree")?;
// The unused slots are serialized too: reject hidden non-finite/excessive values.
for q in p {
point(*q)?;
}
controls(p.get(..=usize::from(n)).ok_or(Invalid("Bezier degree"))?)?;
}
}
require(curve.geometry.start().is_finite() && curve.geometry.end().is_finite(), "non-finite curve endpoint")?;
curve.geometry.check(MAX_NUMBER).map_err(Invalid)?;
match curve.accuracy {
CurveAccuracy::Analytic => Ok(()),
CurveAccuracy::Bounded { max_deviation_mm } => deviation(max_deviation_mm),
+15 -1
View File
@@ -1,4 +1,4 @@
use std::f64::consts::PI;
use std::f64::consts::{PI, TAU};
use super::*;
use crate::{MAX_BEZIER_CTRL, bezier};
@@ -407,3 +407,17 @@ fn connected_bow_tie_and_wrong_winding_are_not_certified_or_silently_repaired()
let converted = reversed.to_region(0, TOL).unwrap();
assert!((converted.outer.signed_area() + 6000.0).abs() < 1e-10);
}
/// A curve's accuracy scales with it: a bound or a sampled deviation by the factor, and an analytic
/// curve stays analytic (W17a: paper error = scale × model error).
#[test]
fn an_accuracy_scales_with_its_curve() {
assert_eq!(CurveAccuracy::Analytic.scaled(0.5), CurveAccuracy::Analytic);
assert_eq!(CurveAccuracy::Sampled { max_sample_deviation_mm: 0.01 }.scaled(3.0), CurveAccuracy::Sampled { max_sample_deviation_mm: 0.03 });
assert_eq!(CurveAccuracy::Bounded { max_deviation_mm: 0.01 }.scaled(2.0), CurveAccuracy::Bounded { max_deviation_mm: 0.02 });
assert_eq!(
CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 }.scaled(5.0),
CurveAccuracy::Sampled { max_sample_deviation_mm: 0.0 },
"a sampled zero stays sampled"
);
}
+45
View File
@@ -783,3 +783,48 @@ fn a_box_from_points_holds_them_and_grows() {
assert_eq!((both.min, both.max), (Vec3::new(0.0, 1.0, 4.0), Vec3::new(1.0, 2.0, 5.0)));
assert!(b.intersection(&far).is_empty());
}
/// A similarity moves every kind of segment exactly: the moved segment's points are the original's
/// points moved, its length scales, and an arc stays an arc turning the same way.
#[test]
fn a_similarity_moves_each_kind_of_segment_exactly() {
let m = Similarity2::new(2.5, 0.7, Vec2::new(-3.0, 11.0)).unwrap();
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
for (i, q) in p.iter_mut().take(6).enumerate() {
*q = Vec2::new(i as f64, (i * i) as f64 * 0.3);
}
let segments = [
Seg2::Line { a: Vec2::new(1.0, 2.0), b: Vec2::new(4.0, -1.0) },
Seg2::Arc { center: Vec2::new(2.0, 1.0), radius: 3.0, start: 0.4, sweep: -2.1 },
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, 2.0), p2: Vec2::new(3.0, 2.0), p3: Vec2::new(4.0, 0.0) },
Seg2::Conic { a: Vec2::new(10.0, 0.0), apex: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0), w: std::f64::consts::FRAC_1_SQRT_2 },
Seg2::Bezier { n: 5, p },
];
for seg in segments {
let moved = seg.similar(&m);
for i in 0..=8 {
let t = f64::from(i) / 8.0;
assert!((moved.point_at(t) - m.apply(seg.point_at(t))).len() < 1e-9, "{seg:?} at {t}");
}
assert!((moved.length() - seg.length() * m.scale()).abs() < 1e-9 * seg.length().max(1.0), "{seg:?}");
assert_eq!(std::mem::discriminant(&moved), std::mem::discriminant(&seg));
}
// An arc keeps its sweep: the turning direction survives a similarity.
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep: -1.0 };
assert!(matches!(arc.similar(&m), Seg2::Arc { sweep, .. } if sweep == -1.0));
// The identity changes nothing.
let identity = Similarity2::new(1.0, 0.0, Vec2::ZERO).unwrap();
for seg in segments {
assert_eq!(seg.similar(&identity), seg);
}
// A similarity scales by a finite number above 0, and turns and moves by finite ones.
for (scale, angle, shift) in [
(0.0, 0.0, Vec2::ZERO),
(-1.0, 0.0, Vec2::ZERO),
(f64::NAN, 0.0, Vec2::ZERO),
(1.0, f64::INFINITY, Vec2::ZERO),
(1.0, 0.0, Vec2::new(f64::NAN, 0.0)),
] {
assert!(Similarity2::new(scale, angle, shift).is_none(), "{scale} {angle} {shift:?}");
}
}
+230
View File
@@ -0,0 +1,230 @@
//! Title blocks: the block of cells that identifies a drawing and what its sheet documents, as
//! data in sheet millimetres.
//!
//! A [`TitleBlock`] is a definition: its size, its two line widths and its cells. Each [`Cell`] is
//! a rectangle measured from the block's lower-right corner (x at most 0, y at least 0), with a
//! label and a value: text from cell content ([`crate::cell`]), or the projection symbol. The block
//! sits on the drawing border's lower-right corner, so one definition serves every sheet it fits.
//! [`TitleBlock::validate`] checks a definition on its own, as when one is read, and
//! [`TitleBlock::layout`] places it on one sheet by the cell text rule: each cell's rectangle, where
//! its label goes and the box its value goes in. Values are set in their boxes once the paper scene
//! has text; nothing here is saved or drawn yet.
//!
//! The outline is drawn `outline_line_mm` wide and the lines between cells `cell_line_mm`, each
//! centred on its edge. The block's right and bottom edges lie on the drawing border, whose line is
//! centred there too. A cell's text is padded from the inner edge of its lines and of the border's
//! line, wherever that reaches, and its descenders never reach a line (the cell text rule), so no
//! line covers it.
use serde::{Deserialize, Serialize};
use crate::cell::{self, Content, Fit, HAlign};
use crate::drawing_sheet::SheetLayout;
use crate::paper::MAX_LINE_MM;
use crate::{Aabb2, Vec2};
/// The most cells a block may have.
pub const MAX_CELLS: usize = 64;
const MIN_BLOCK_SIDE_MM: f64 = 1.0;
const MAX_BLOCK_SIDE_MM: f64 = 1000.0;
const MAX_NAME_BYTES: usize = 64;
/// A title block's definition.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TitleBlock {
/// The block's width and height.
pub size_mm: Vec2,
pub outline_line_mm: f64,
pub cell_line_mm: f64,
pub cells: Vec<Cell>,
}
/// One cell of a block.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Cell {
/// Snake case, and no other cell of the block has it.
pub name: String,
/// `[x0, y0, x1, y1]` from the block's lower-right corner: `-width <= x0 < x1 <= 0` and
/// `0 <= y0 < y1 <= height`.
pub rect_mm: [f64; 4],
/// None: the value has the whole cell.
pub label: Option<Label>,
pub kind: CellKind,
}
/// A cell's label, such as "Created by": one line at its top left.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Label {
pub text: String,
pub cap_mm: f64,
}
/// What a cell's value is. Externally tagged (`{"text": {...}}`, `"projection_symbol"`), as serde
/// ignores extra fields on an internally tagged unit variant.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum CellKind {
Text(TextCell),
/// The projection symbol, drawn from the drawing's projection (`drawing.projection`), which the
/// views read too: never fixed in the block.
ProjectionSymbol,
}
/// A text value: its content, its cap height, where its lines sit across the value box (left when
/// not given) and what it should fit (nothing when not given).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TextCell {
pub content: Content,
pub cap_mm: f64,
#[serde(default)]
pub align: HAlign,
#[serde(default)]
pub fit: Fit,
}
/// A block placed on one sheet: what a writer draws, before the values are set.
#[derive(Clone, Debug, PartialEq)]
pub struct TitleBlockLayout {
pub outline: Aabb2,
pub outline_line_mm: f64,
pub cell_line_mm: f64,
/// One for each of the definition's cells, in its order.
pub cells: Vec<CellLayout>,
}
/// One cell placed on a sheet.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CellLayout {
pub rect: Aabb2,
/// Where the label's baseline starts, when the cell has one.
pub label_at: Option<Vec2>,
pub value_box: Aabb2,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error("title block: {0}")]
pub struct Invalid(pub &'static str);
type Result<T> = std::result::Result<T, Invalid>;
fn require(condition: bool, message: &'static str) -> Result<()> {
if condition { Ok(()) } else { Err(Invalid(message)) }
}
/// A lowercase letter, then lowercase letters, digits and underscores: at most 64 in all.
fn is_snake_case(name: &str) -> bool {
let mut bytes = name.bytes();
name.len() <= MAX_NAME_BYTES
&& bytes.next().is_some_and(|b| b.is_ascii_lowercase())
&& bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
}
/// Whether two rectangles share more than an edge.
fn overlap(a: Aabb2, b: Aabb2) -> bool {
a.min.x < b.max.x && b.min.x < a.max.x && a.min.y < b.max.y && b.min.y < a.max.y
}
impl Cell {
/// The cell's rectangle with the block's lower-right corner at `corner`.
fn rect(&self, corner: Vec2) -> Aabb2 {
let [x0, y0, x1, y1] = self.rect_mm;
Aabb2 { min: corner + Vec2::new(x0, y0), max: corner + Vec2::new(x1, y1) }
}
/// The box its value goes in, in `inside`, the cell inside its lines.
fn value_box(&self, inside: Aabb2) -> Aabb2 {
cell::value_box(inside, self.label.as_ref().map(|label| label.cap_mm))
}
}
impl TitleBlock {
/// `cell`'s rectangle `rect` inside its lines: each side moved in by half the line drawn on it,
/// the outline's on the block's edges and a cell line's inside the block.
fn inside_lines(&self, cell: &Cell, rect: Aabb2) -> Aabb2 {
let [x0, y0, x1, y1] = cell.rect_mm;
let half = |on_outline: bool| if on_outline { self.outline_line_mm } else { self.cell_line_mm } / 2.0;
let lower = Vec2::new(half(x0 == -self.size_mm.x), half(y0 == 0.0));
let upper = Vec2::new(half(x1 == 0.0), half(y1 == self.size_mm.y));
Aabb2 { min: rect.min + lower, max: rect.max - upper }
}
/// Each cell with the block's lower-right corner at `corner`, inside its lines and inside
/// `clear` too when given (the drawing border's line's inner edge), checked by the cell text
/// rule: room for a value below its label, and for one line of a text value.
fn place_cells(&self, corner: Vec2, clear: Option<Aabb2>) -> Result<Vec<CellLayout>> {
let place = |cell: &Cell| {
let rect = cell.rect(corner);
let inside = self.inside_lines(cell, rect);
let inside = clear.map_or(inside, |clear| inside.intersection(&clear));
let value_box = cell.value_box(inside);
let room = value_box.size();
require(room.x > 0.0 && room.y > 0.0, "a cell has room for its value below its label")?;
if let CellKind::Text(text) = &cell.kind {
let one_line = cell::place_lines(value_box, 1, text.cap_mm, text.cap_mm, text.align, false);
require(matches!(one_line, Ok((_, None))), "a value's cap height is above 0 and one line of it fits its value box")?;
}
let label_at = cell.label.as_ref().map(|label| cell::label_at(inside, label.cap_mm));
Ok(CellLayout { rect, label_at, value_box })
};
self.cells.iter().map(place).collect()
}
/// Checks a definition on its own, counting its cells first: call it when one is read. Its
/// cells' room is checked with the corner at the origin and only the outline on its edges;
/// [`Self::layout`] calls it too, and checks their room again where the block sits.
pub fn validate(&self) -> Result<()> {
require(self.cells.len() <= MAX_CELLS, "a title block has at most 64 cells")?;
let Vec2 { x: width, y: height } = self.size_mm;
let sides = MIN_BLOCK_SIDE_MM..=MAX_BLOCK_SIDE_MM;
require(sides.contains(&width) && sides.contains(&height), "each side of a title block is 1 to 1000 mm")?;
let lines = [self.outline_line_mm, self.cell_line_mm];
require(lines.into_iter().all(|line| line > 0.0 && line <= MAX_LINE_MM), "a title block's lines are above 0 and at most 10 mm wide")?;
for (i, cell) in self.cells.iter().enumerate() {
let earlier = || self.cells.iter().take(i);
require(is_snake_case(&cell.name), "a cell's name is snake case and at most 64 characters")?;
require(earlier().all(|other| other.name != cell.name), "no two cells have the same name")?;
let [x0, y0, x1, y1] = cell.rect_mm;
require(-width <= x0 && x0 < x1 && x1 <= 0.0 && 0.0 <= y0 && y0 < y1 && y1 <= height, "a cell lies inside its block")?;
let rect = cell.rect(Vec2::ZERO);
require(earlier().all(|other| !overlap(other.rect(Vec2::ZERO), rect)), "no two cells overlap")?;
if let Some(label) = &cell.label {
require(label.cap_mm.is_finite() && label.cap_mm > 0.0, "a label's cap height is finite and above 0")?;
cell::check_label(&label.text).map_err(|cell::Invalid(message)| Invalid(message))?;
}
if let CellKind::Text(text) = &cell.kind {
text.content.validate().map_err(|cell::Invalid(message)| Invalid(message))?;
}
}
self.place_cells(Vec2::ZERO, None)?;
Ok(())
}
/// The block on the lower-right corner of `sheet`'s drawing border, which must hold it, each
/// cell with room inside the border's line too.
pub fn layout(&self, sheet: &SheetLayout) -> Result<TitleBlockLayout> {
self.validate()?;
let border = sheet.drawing_border;
let (size, room) = (self.size_mm, border.size());
let fits = border.min.is_finite() && border.max.is_finite() && size.x <= room.x && size.y <= room.y;
require(fits, "a title block fits inside the drawing border")?;
let corner = Vec2::new(border.max.x, border.min.y);
// Checked again here: the border's line, centred on it, runs along the block's right and
// bottom edges (and any other it reaches), and rounding at the real corner may take the
// last of a cell's room.
let cells = self.place_cells(corner, Some(border.inflated(-sheet.drawing_border_line_mm / 2.0)))?;
Ok(TitleBlockLayout {
outline: Aabb2 { min: corner - Vec2::new(size.x, 0.0), max: corner + Vec2::new(0.0, size.y) },
outline_line_mm: self.outline_line_mm,
cell_line_mm: self.cell_line_mm,
cells,
})
}
}
#[cfg(test)]
#[path = "title_block_tests.rs"]
mod tests;
+545
View File
@@ -0,0 +1,545 @@
use serde_json::json;
use super::*;
use crate::bspline::tests::Lcg;
use crate::cell::{DESCENDER_OF_CAP, Format, PADDING_MM, Part, Source, Warning, place_lines};
use crate::drawing_sheet::{DrawingBorderProfile, Orientation, PaperSize, Sheet, SheetSize};
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Aabb2 {
Aabb2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }
}
fn field(source: Source, key: &str) -> Part {
Part::Field { source, key: key.to_string(), format: Format::default() }
}
fn text(cap_mm: f64, align: HAlign, fit: Fit, parts: Vec<Part>) -> CellKind {
CellKind::Text(TextCell { content: Content(parts), cap_mm, align, fit })
}
/// The S2 plan's 180 x 54 mm default, as test data: twelve cells under 2.5 mm labels.
fn block() -> TitleBlock {
use HAlign::{Centre, Left};
use Source::{Component, Drawing, Revision};
let count = |n| Fit { max_chars: Some(n), ..Fit::default() };
let (whole, wrap) = (Fit::default(), Fit { wrap: true, ..Fit::default() });
let title = Fit { max_chars: Some(25), wide_max_chars: Some(30), wrap: false };
let sheet = vec![field(Source::Sheet, "number"), Part::Text("/".into()), field(Source::Sheet, "count")];
let cells = [
("title", [-180.0, 0.0, -68.0, 14.0], "Title", text(5.0, Left, title, vec![field(Component, "name")])),
("identification_number", [-68.0, 0.0, 0.0, 14.0], "Identification number", text(5.0, Left, count(16), vec![field(Drawing, "number")])),
("created_by", [-180.0, 14.0, -121.0, 24.0], "Created by", text(3.5, Left, count(20), vec![field(Revision, "designer")])),
("approved_by", [-121.0, 14.0, -62.0, 24.0], "Approved by", text(3.5, Left, count(20), vec![field(Revision, "checker")])),
("date_of_issue", [-62.0, 14.0, -31.0, 24.0], "Date of issue", text(3.5, Left, count(10), vec![field(Revision, "date")])),
("sheet", [-31.0, 14.0, 0.0, 24.0], "Sheet", text(3.5, Centre, count(9), sheet)),
("material", [-180.0, 24.0, -100.0, 34.0], "Material", text(3.5, Left, whole, vec![field(Component, "material")])),
("finish", [-100.0, 24.0, -30.0, 34.0], "Finish", text(3.5, Left, whole, vec![field(Component, "properties.finish")])),
("mass", [-30.0, 24.0, 0.0, 34.0], "Mass", text(3.5, Centre, whole, vec![field(Component, "mass")])),
("legal_owner", [-180.0, 34.0, -100.0, 54.0], "Legal owner", text(3.5, Left, wrap, vec![field(Drawing, "properties.legal_owner")])),
(
"general_tolerances",
[-100.0, 34.0, -30.0, 54.0],
"General tolerances",
text(2.5, Left, wrap, vec![field(Drawing, "properties.general_tolerances")]),
),
("projection", [-30.0, 34.0, 0.0, 54.0], "Projection", CellKind::ProjectionSymbol),
];
let cells = cells
.into_iter()
.map(|(name, rect_mm, label, kind)| Cell { name: name.into(), rect_mm, label: Some(Label { text: label.into(), cap_mm: 2.5 }), kind })
.collect();
TitleBlock { size_mm: Vec2::new(180.0, 54.0), outline_line_mm: 0.7, cell_line_mm: 0.35, cells }
}
fn sheet_layout(profile: &DrawingBorderProfile, size: PaperSize, orientation: Orientation) -> SheetLayout {
profile.layout(&Sheet::standard(size, orientation)).unwrap()
}
fn iso(size: PaperSize, orientation: Orientation) -> SheetLayout {
sheet_layout(&DrawingBorderProfile::iso(), size, orientation)
}
fn filing(size: PaperSize, orientation: Orientation) -> SheetLayout {
sheet_layout(&DrawingBorderProfile { filing_margin: true, ..DrawingBorderProfile::iso() }, size, orientation)
}
fn cell_named<'a>(block: &TitleBlock, layout: &'a TitleBlockLayout, name: &str) -> &'a CellLayout {
let i = block.cells.iter().position(|c| c.name == name).unwrap();
&layout.cells[i]
}
/// `a` and `b` within 1e-9 mm at both corners.
fn near(a: Aabb2, b: Aabb2) -> bool {
a.min.dist(b.min) < 1e-9 && a.max.dist(b.max) < 1e-9
}
/// `cell`'s rectangle placed at `corner`, inside its lines by the module's rule worked out by hand:
/// half the 0.7 mm outline on the block's edges, half the 0.35 mm cell line inside.
fn inside(cell: &Cell, corner: Vec2) -> Aabb2 {
let [x0, y0, x1, y1] = cell.rect_mm;
let half = |outline: bool| if outline { 0.35 } else { 0.175 };
Aabb2 {
min: corner + Vec2::new(x0 + half(x0 == -180.0), y0 + half(y0 == 0.0)),
max: corner + Vec2::new(x1 - half(x1 == 0.0), y1 - half(y1 == 54.0)),
}
}
fn text_of(cell: &Cell) -> &TextCell {
match &cell.kind {
CellKind::Text(text) => text,
CellKind::ProjectionSymbol => panic!("{} has no text", cell.name),
}
}
#[test]
fn the_default_geometry_validates_and_its_cells_tile_the_block() {
let block = block();
block.validate().unwrap();
assert_eq!(block.cells.len(), 12);
let area: f64 = block.cells.iter().map(|c| c.rect(Vec2::ZERO).size()).map(|s| s.x * s.y).sum();
assert_eq!((area, 180.0 * 54.0), (9720.0, 9720.0));
// Each text cell's value box holds one line of its cap height, centred, its descenders above the
// lines' inner edge.
for cell in &block.cells {
let room = cell.value_box(block.inside_lines(cell, cell.rect(Vec2::ZERO))).size();
if let CellKind::Text(text) = &cell.kind {
let below = (room.y - text.cap_mm) / 2.0 + PADDING_MM;
assert!(text.cap_mm <= room.y && text.cap_mm * DESCENDER_OF_CAP <= below, "{}: {} in {room:?}", cell.name, text.cap_mm);
}
}
}
#[test]
fn on_a3_landscape_the_block_sits_on_the_drawing_borders_lower_right_corner() {
let (block, sheet) = (block(), iso(PaperSize::A3, Orientation::Landscape));
let layout = block.layout(&sheet).unwrap();
assert_eq!(layout.outline, rect(230.0, 10.0, 410.0, 64.0));
assert_eq!(layout.outline.size().x, 180.0);
assert_eq!((layout.outline_line_mm, layout.cell_line_mm), (0.7, 0.35));
assert_eq!(layout.cells.len(), 12);
// The title cell, on the outline at its left and bottom (0.35 mm in), a cell line at its right
// and top (0.175 in): its label's baseline 1 + 2.5 mm below its top's inner edge, its value box
// 1 mm in from the lines' inner edges and 1 mm under the label.
let title = cell_named(&block, &layout, "title");
assert_eq!(title.rect, rect(230.0, 10.0, 342.0, 24.0));
assert!(title.label_at.is_some_and(|at| at.dist(Vec2::new(231.35, 20.325)) < 1e-9), "{:?}", title.label_at);
assert!(near(title.value_box, rect(231.35, 11.35, 340.825, 19.325)), "{:?}", title.value_box);
let (at, warning) = place_lines(title.value_box, 1, 5.0, 8.0, HAlign::Left, false).unwrap();
assert!(at.dist(Vec2::new(231.35, 12.8375)) < 1e-9 && warning.is_none(), "{at:?}");
// The projection cell, on the outline at its right and top.
let projection = cell_named(&block, &layout, "projection");
assert_eq!(projection.rect, rect(380.0, 44.0, 410.0, 64.0));
assert!(projection.label_at.is_some_and(|at| at.dist(Vec2::new(381.175, 60.15)) < 1e-9), "{:?}", projection.label_at);
assert!(near(projection.value_box, rect(381.175, 45.175, 408.65, 59.15)), "{:?}", projection.value_box);
// A cell without a label gives its value the whole cell inside its lines, padded.
let mut bare = block.clone();
bare.cells[0].label = None;
let title = bare.layout(&sheet).unwrap().cells[0];
assert!(title.label_at.is_none() && near(title.value_box, rect(231.35, 11.35, 340.825, 22.825)), "{:?}", title.value_box);
}
/// Padding is measured from a line's inner edge: a 3 mm outline moves the title's text 1.5 mm in
/// from where a hairline puts it, so the line never covers it. The drawing border's line is a
/// hairline too, so only the outline moves the text.
#[test]
fn the_padding_starts_at_the_lines_inner_edge() {
let sheet = SheetLayout { drawing_border_line_mm: 1e-9, ..iso(PaperSize::A3, Orientation::Landscape) };
let hairline = TitleBlock { outline_line_mm: 1e-9, cell_line_mm: 1e-9, ..block() }.layout(&sheet).unwrap();
let wide = TitleBlock { outline_line_mm: 3.0, ..block() }.layout(&sheet).unwrap();
let (thin, thick) = (hairline.cells[0].value_box, wide.cells[0].value_box);
assert!((thick.min.x - thin.min.x - 1.5).abs() < 1e-6 && (thick.min.y - thin.min.y - 1.5).abs() < 1e-6, "{thin:?} {thick:?}");
// The outline's inner edge is 1.5 mm inside the cell's left side; the text starts 1 mm past it.
assert!((thick.min.x - (230.0 + 1.5 + 1.0)).abs() < 1e-9, "{thick:?}");
}
/// The block's right and bottom edges lie on the drawing border, its line centred on them too: where
/// that line is wider than the outline, the cells there are padded from its inner edge instead.
#[test]
fn a_drawing_border_line_wider_than_the_outline_pads_the_right_and_bottom_cells() {
let a3 = |line_mm: f64| SheetLayout { drawing_border_line_mm: line_mm, ..iso(PaperSize::A3, Orientation::Landscape) };
let block = block();
let boxes = |block: &TitleBlock, line_mm: f64| {
let layout = block.layout(&a3(line_mm)).unwrap();
["title", "identification_number", "projection"].map(|name| cell_named(block, &layout, name).value_box)
};
// ISO's 0.7 mm, the outline's width, and a narrower line leave the outline's inner edge.
assert_eq!(boxes(&block, 0.35), boxes(&block, 0.7));
// A 2 mm line's inner edge is at y = 11 and x = 409: the value boxes start 1 mm above it and end
// 1 mm left of it; the block's left and top edges keep the outline's.
let [title, number, projection] = boxes(&block, 2.0);
assert!(near(title, rect(231.35, 12.0, 340.825, 19.325)), "{title:?}");
assert!(near(number, rect(343.175, 12.0, 408.0, 19.325)), "{number:?}");
assert!(near(projection, rect(381.175, 45.175, 408.0, 59.15)), "{projection:?}");
// A 5 mm line's inner edge is at y = 12.5 and x = 407.5: the title's 5 mm line no longer fits
// above it, so the block that validates on its own is refused on this sheet. With 3.5 mm values,
// the boxes keep 1 mm from it.
block.validate().unwrap();
assert_eq!(block.layout(&a3(5.0)), Err(Invalid("a value's cap height is above 0 and one line of it fits its value box")));
let mut smaller = block.clone();
for cell in &mut smaller.cells[..2] {
if let CellKind::Text(text) = &mut cell.kind {
text.cap_mm = 3.5;
}
}
let [title, number, _] = boxes(&smaller, 5.0);
assert!(near(title, rect(231.35, 13.5, 340.825, 19.325)) && near(number, rect(343.175, 13.5, 406.5, 19.325)), "{title:?} {number:?}");
// A block that fills the drawing border has its left and top edges on the border's line too:
// on a 200 x 74 sheet the border is (10, 10)-(190, 64), and a 2 mm line's inner edge is at x = 11
// and y = 63.
let sheet = |sides_mm| Sheet { size: SheetSize::Custom { sides_mm }, orientation: Orientation::Landscape };
let filled = SheetLayout { drawing_border_line_mm: 2.0, ..DrawingBorderProfile::iso().layout(&sheet([200.0, 74.0])).unwrap() };
let layout = block.layout(&filled).unwrap();
let (title, projection) = (cell_named(&block, &layout, "title"), cell_named(&block, &layout, "projection"));
assert!(near(title.value_box, rect(12.0, 12.0, 120.825, 19.325)), "{:?}", title.value_box);
assert!(projection.label_at.is_some_and(|at| at.dist(Vec2::new(161.175, 59.5)) < 1e-9), "{:?}", projection.label_at);
assert!(near(projection.value_box, rect(161.175, 45.175, 188.0, 58.5)), "{:?}", projection.value_box);
}
/// A cell with a few ulps of room at the origin, where a definition is checked on its own, has none
/// where A3's corner (x = 410) rounds it: the block is refused there, not laid out inside out, and
/// placed where rounding leaves the room (A2's corner, x = 584).
#[test]
fn a_cells_room_is_checked_again_where_the_block_sits() {
let sliver = Cell { rect_mm: [-70.34999999999971, 0.0, -67.9999999999997, 14.0], ..block().cells[0].clone() };
let block = TitleBlock { cells: vec![sliver], ..block() };
block.validate().unwrap();
const NO_ROOM: Invalid = Invalid("a cell has room for its value below its label");
assert_eq!(block.layout(&iso(PaperSize::A3, Orientation::Landscape)), Err(NO_ROOM));
let value_box = block.layout(&iso(PaperSize::A2, Orientation::Landscape)).unwrap().cells[0].value_box;
assert!(value_box.min.x < value_box.max.x, "{value_box:?}");
}
#[test]
fn the_block_follows_the_drawing_border_on_a2_and_a4() {
let block = block();
assert_eq!(block.layout(&iso(PaperSize::A2, Orientation::Landscape)).unwrap().outline, rect(404.0, 10.0, 584.0, 64.0));
// A4 upright: 10 mm to spare on the left of the 190 mm drawing border, none with the filing margin.
let a4 = iso(PaperSize::A4, Orientation::Portrait);
assert_eq!(a4.drawing_border, rect(10.0, 10.0, 200.0, 287.0));
assert_eq!(block.layout(&a4).unwrap().outline, rect(20.0, 10.0, 200.0, 64.0));
let a4 = filing(PaperSize::A4, Orientation::Portrait);
let outline = block.layout(&a4).unwrap().outline;
assert_eq!((outline.min.x, outline), (a4.drawing_border.min.x, rect(20.0, 10.0, 200.0, 64.0)));
}
#[test]
fn a5_landscape_holds_the_block_and_a5_portrait_is_too_narrow() {
const TOO_BIG: Invalid = Invalid("a title block fits inside the drawing border");
let block = block();
assert_eq!(block.layout(&iso(PaperSize::A5, Orientation::Landscape)).unwrap().outline, rect(20.0, 10.0, 200.0, 64.0));
// With the filing margin, the drawing border is exactly the block's width.
let a5 = filing(PaperSize::A5, Orientation::Landscape);
assert_eq!(a5.drawing_border.size().x, 180.0);
assert_eq!(block.layout(&a5).unwrap().outline, rect(20.0, 10.0, 200.0, 64.0));
// Upright, the drawing border is 128 mm wide, or 118 with the filing margin.
for sheet in [iso(PaperSize::A5, Orientation::Portrait), filing(PaperSize::A5, Orientation::Portrait)] {
assert!(sheet.drawing_border.size().x < 180.0);
assert_eq!(block.layout(&sheet), Err(TOO_BIG));
}
// A drawing border of exactly 180 x 54 holds it; a hair less either way doesn't.
let custom = |a: f64, b: f64| {
DrawingBorderProfile::iso().layout(&Sheet { size: SheetSize::Custom { sides_mm: [a, b] }, orientation: Orientation::Landscape })
};
let exact = custom(200.0, 74.0).unwrap();
assert_eq!(block.layout(&exact).unwrap().outline, exact.drawing_border);
for sheet in [custom(199.999, 74.0), custom(200.0, 73.999)] {
assert_eq!(block.layout(&sheet.unwrap()), Err(TOO_BIG));
}
}
/// On every standard sheet either way, with the filing margin or without, the block sits on the
/// drawing border's lower-right corner and each cell keeps its place from it; only A5 upright is
/// too narrow.
#[test]
fn on_every_sheet_each_cell_keeps_its_place_from_the_corner() {
let block = block();
let mut refused = Vec::new();
for size in PaperSize::ALL {
for orientation in [Orientation::Landscape, Orientation::Portrait] {
for sheet in [iso(size, orientation), filing(size, orientation)] {
let border = sheet.drawing_border;
let Ok(layout) = block.layout(&sheet) else {
assert!(border.size().x < 180.0 || border.size().y < 54.0, "{size:?} {orientation:?}");
refused.push((size, orientation));
continue;
};
let corner = Vec2::new(border.max.x, border.min.y);
assert_eq!(layout.outline, Aabb2 { min: corner - Vec2::new(180.0, 0.0), max: corner + Vec2::new(0.0, 54.0) });
for (cell, placed) in block.cells.iter().zip(&layout.cells) {
let [x0, y0, x1, y1] = cell.rect_mm;
let expected = Aabb2 { min: corner + Vec2::new(x0, y0), max: corner + Vec2::new(x1, y1) };
assert_eq!(placed.rect, expected, "{size:?} {orientation:?} {}", cell.name);
let lines = inside(cell, corner);
let label_at = Vec2::new(lines.min.x + 1.0, lines.max.y - 3.5);
assert!(placed.label_at.is_some_and(|at| at.dist(label_at) < 1e-9), "{}", cell.name);
let value_box = Aabb2 { min: lines.min + Vec2::new(1.0, 1.0), max: Vec2::new(lines.max.x - 1.0, lines.max.y - 4.5) };
assert!(near(placed.value_box, value_box), "{}", cell.name);
}
}
}
}
assert_eq!(refused, [(PaperSize::A5, Orientation::Portrait); 2]);
}
/// The plan's line counts for the two wrapped cells at Liberation Sans' pitch (2355/1409 of the cap
/// height): two lines of 3.5 mm for the legal owner, three of 2.5 mm for the general tolerances.
#[test]
fn the_wrapped_cells_hold_the_lines_the_plan_gives_them() {
let block = block();
let layout = block.layout(&iso(PaperSize::A3, Orientation::Landscape)).unwrap();
for (name, lines) in [("legal_owner", 2), ("general_tolerances", 3)] {
let i = block.cells.iter().position(|c| c.name == name).unwrap();
let text = text_of(&block.cells[i]);
let pitch = text.cap_mm * 2355.0 / 1409.0;
let place = |n| place_lines(layout.cells[i].value_box, n, text.cap_mm, pitch, text.align, text.fit.wrap).unwrap();
// From the value box's top, 59.15 mm up the sheet: under the 0.7 mm outline's inner edge.
let (at, warning) = place(lines);
assert!(at.dist(Vec2::new(layout.cells[i].value_box.min.x, 59.15 - text.cap_mm)) < 1e-9 && warning.is_none(), "{name}: {at:?}");
assert_eq!(place(lines + 1).1, Some(Warning::Height), "{name}");
}
}
#[test]
fn hostile_definitions_are_refused() {
let a3 = iso(PaperSize::A3, Orientation::Landscape);
let with = |change: &dyn Fn(&mut TitleBlock)| {
let mut block = block();
change(&mut block);
block
};
let named = |name: &'static str| move |b: &mut TitleBlock| b.cells[0].name = name.into();
let at = |rect_mm: [f64; 4]| move |b: &mut TitleBlock| b.cells[0].rect_mm = rect_mm;
let label_cap = |cap_mm: f64| move |b: &mut TitleBlock| b.cells[0].label = Some(Label { text: "Title".into(), cap_mm });
let label_text = |text: String| move |b: &mut TitleBlock| b.cells[0].label = Some(Label { text: text.clone(), cap_mm: 2.5 });
let value_cap = |cap_mm: f64| move |b: &mut TitleBlock| b.cells[0].kind = text(cap_mm, HAlign::Left, Fit::default(), vec![]);
let mut cases: Vec<(TitleBlock, &str)> = Vec::new();
for side in [0.999, 1000.001, 0.0, -180.0, f64::NAN, f64::INFINITY] {
cases.push((with(&|b| b.size_mm.x = side), "each side of a title block is 1 to 1000 mm"));
cases.push((with(&|b| b.size_mm.y = side), "each side of a title block is 1 to 1000 mm"));
}
for line in [0.0, -0.7, 10.000_001, f64::NAN, f64::INFINITY] {
cases.push((with(&|b| b.outline_line_mm = line), "a title block's lines are above 0 and at most 10 mm wide"));
cases.push((with(&|b| b.cell_line_mm = line), "a title block's lines are above 0 and at most 10 mm wide"));
}
let (longest, too_long): (&'static str, &'static str) = ("a".repeat(64).leak(), "a".repeat(65).leak());
for name in ["", "Title", "title_Block", "1st", "title-block", "title block", "_title", "titlé", too_long] {
cases.push((with(&named(name)), "a cell's name is snake case and at most 64 characters"));
}
cases.push((with(&|b| b.cells[1].name = "title".into()), "no two cells have the same name"));
for rect_mm in [
[-180.0, 0.0, 0.001, 14.0],
[-180.0, -0.001, -68.0, 14.0],
[-180.001, 0.0, -68.0, 14.0],
[-180.0, 40.0, -68.0, 54.001],
[-68.0, 0.0, -68.0, 14.0],
[-180.0, 14.0, -68.0, 0.0],
[f64::NAN, 0.0, -68.0, 14.0],
[-180.0, 0.0, -68.0, f64::INFINITY],
] {
cases.push((with(&at(rect_mm)), "a cell lies inside its block"));
}
// The identification number reaching 1 mm into the title, a copy of the title, a cell inside the
// material cell.
cases.push((with(&|b| b.cells[1].rect_mm = [-69.0, 0.0, 0.0, 14.0]), "no two cells overlap"));
cases.push((with(&|b| b.cells.push(Cell { name: "copy".into(), ..b.cells[0].clone() })), "no two cells overlap"));
cases.push((
with(&|b| b.cells.push(Cell { name: "inner".into(), rect_mm: [-170.0, 26.0, -150.0, 32.0], ..b.cells[0].clone() })),
"no two cells overlap",
));
for cap in [0.0, -2.5, f64::NAN, f64::INFINITY] {
cases.push((with(&label_cap(cap)), "a label's cap height is finite and above 0"));
}
for text in ["Title\nblock".to_string(), "\t".into(), "a".repeat(4097)] {
cases.push((with(&label_text(text)), "a label is one line of at most 4096 bytes"));
}
// A 14 mm cell under a 12 mm label, a 2 mm wide cell, a symbol under a label filling its cell:
// the projection cell is 19.475 mm high inside its lines, so a label of 16.475 leaves no room.
cases.push((with(&label_cap(12.0)), "a cell has room for its value below its label"));
cases.push((
with(&|b| b.cells[11].label = Some(Label { text: "Projection".into(), cap_mm: 16.475 + 1e-9 })),
"a cell has room for its value below its label",
));
cases.push((with(&at([-180.0, 0.0, -178.0, 14.0])), "a cell has room for its value below its label"));
cases.push((
with(&|b| b.cells[11].label = Some(Label { text: "Projection".into(), cap_mm: 17.0 })),
"a cell has room for its value below its label",
));
// The title's value box is 7.975 mm high inside its lines. A line filling it has its descenders
// 2.39 mm below the box, past the 1 mm padding; the most it holds centred is 9.975 / 1.6 mm, its
// descenders (3/10 of it) then on the outline's inner edge.
let title_most = 9.975 / 1.6;
for cap in [0.0, -5.0, 7.975, title_most + 1e-6, f64::NAN, f64::INFINITY] {
cases.push((with(&value_cap(cap)), "a value's cap height is above 0 and one line of it fits its value box"));
}
// The cell content's own rule, under this module's name.
cases.push((
with(&|b| b.cells[0].kind = text(5.0, HAlign::Left, Fit::default(), vec![Part::Text(String::new()); 65])),
"a cell has at most 64 parts",
));
let control = vec![Part::Text("a\u{0}".into())];
cases.push((
with(&|b| b.cells[0].kind = text(5.0, HAlign::Left, Fit::default(), control.clone())),
"a cell's text has no control character but a line break",
));
let many = |n: usize| {
let cell = |i: usize| Cell {
name: format!("c{i}"),
rect_mm: [-10.0 * (i + 1) as f64, 0.0, -10.0 * i as f64, 10.0],
label: None,
kind: text(1.0, HAlign::Left, Fit::default(), vec![]),
};
TitleBlock { size_mm: Vec2::new(1000.0, 10.0), outline_line_mm: 0.7, cell_line_mm: 0.35, cells: (0..n).map(cell).collect() }
};
cases.push((many(MAX_CELLS + 1), "a title block has at most 64 cells"));
for (block, message) in cases {
assert_eq!(block.validate(), Err(Invalid(message)), "{block:?}");
assert_eq!(block.layout(&a3), Err(Invalid(message)), "{block:?}");
}
// And their limits are accepted.
let accepted = [
many(MAX_CELLS),
TitleBlock { size_mm: Vec2::new(1.0, 1.0), cells: vec![], ..block() },
TitleBlock { size_mm: Vec2::new(1000.0, 1000.0), cells: vec![], ..block() },
// The widest outline on its own: the default's text doesn't fit inside a 10 mm line.
TitleBlock { outline_line_mm: 10.0, cells: vec![], ..block() },
with(&|b| b.cell_line_mm = 1e-9),
with(&named("a")),
with(&named("title_2")),
with(&named(longest)),
with(&value_cap(title_most - 1e-6)),
with(&|b| b.cells[11].label = Some(Label { text: "Projection".into(), cap_mm: 16.475 - 1e-9 })),
with(&label_text("a".repeat(4096))),
with(&|b| b.cells[0].label = None),
];
for block in accepted {
block.validate().unwrap();
}
}
#[test]
fn a_block_round_trips_through_json_and_unknown_fields_are_refused() {
let block = block();
let value = serde_json::to_value(&block).unwrap();
assert_eq!(value["size_mm"], json!([180.0, 54.0]));
assert_eq!((value["outline_line_mm"].clone(), value["cell_line_mm"].clone()), (json!(0.7), json!(0.35)));
assert_eq!(
value["cells"][5],
json!({
"name": "sheet",
"rect_mm": [-31.0, 14.0, 0.0, 24.0],
"label": {"text": "Sheet", "cap_mm": 2.5},
"kind": {"text": {
"content": [
{"field": {"source": "sheet", "key": "number", "format": {"unit": null, "decimals": null}}},
{"text": "/"},
{"field": {"source": "sheet", "key": "count", "format": {"unit": null, "decimals": null}}}
],
"cap_mm": 3.5,
"align": "centre",
"fit": {"max_chars": 9, "wide_max_chars": null, "wrap": false}
}}
})
);
assert_eq!(value["cells"][11]["kind"], json!("projection_symbol"));
let read = |v: serde_json::Value| serde_json::from_value::<TitleBlock>(v);
assert_eq!(read(value.clone()).unwrap(), block);
// Not given: no label, left-aligned, nothing to fit.
let mut plain = value.clone();
for key in ["align", "fit"] {
plain["cells"][0]["kind"]["text"].as_object_mut().unwrap().remove(key);
}
plain["cells"][0].as_object_mut().unwrap().remove("label");
let plain = read(plain).unwrap();
assert_eq!((plain.cells[0].label.clone(), text_of(&plain.cells[0]).align, text_of(&plain.cells[0]).fit), (None, HAlign::Left, Fit::default()));
for at in ["", "/cells/0", "/cells/0/label", "/cells/0/kind/text", "/cells/0/kind/text/fit"] {
let mut v = value.clone();
v.pointer_mut(at).unwrap()["scale"] = json!(1);
assert!(read(v).is_err(), "an unknown field at {at:?}");
}
for (at, bad) in [
("/cells/11/kind", json!("logo")),
("/cells/11/kind", json!("projection")),
("/cells/11/kind", json!({"projection_symbol": {"angle": "third"}})),
("/cells/11/kind", json!({"kind": "projection_symbol"})),
("/cells/0/rect_mm", json!([-180.0, 0.0, -68.0])),
("/cells/0/rect_mm", json!({"x0": -180.0})),
("/cells/0/name", json!(1)),
("/size_mm", json!([180.0])),
] {
let mut v = value.clone();
*v.pointer_mut(at).unwrap() = bad.clone();
assert!(read(v).is_err(), "{at}: {bad}");
}
}
#[test]
fn a_drawing_border_that_isnt_finite_holds_no_block() {
let block = block();
let a3 = iso(PaperSize::A3, Orientation::Landscape);
for drawing_border in [Aabb2::EMPTY, rect(410.0, 10.0, 10.0, 287.0), rect(f64::NAN, 10.0, 410.0, 287.0), rect(10.0, 10.0, f64::INFINITY, 287.0)] {
let sheet = SheetLayout { drawing_border, ..a3.clone() };
assert_eq!(block.layout(&sheet), Err(Invalid("a title block fits inside the drawing border")), "{drawing_border:?}");
}
}
/// Random changes to the default's numbers, hostile ones among them: a definition is accepted or
/// refused without panicking, and an accepted one placed on a sheet it fits (any standard size,
/// either way, with or without the filing margin, any drawing border line) keeps every cell inside
/// the outline, apart from the others, with its label and value box inside it, and every value box
/// padded from the drawing border's line.
#[test]
fn fuzzed_definitions_never_panic_and_an_accepted_one_lays_out_inside_its_outline() {
const NUMBERS: [f64; 9] = [0.0, -0.0, 1e-9, 1e5, -1e5, 1000.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
let mut rng = Lcg(0x7b1c);
let (mut accepted, mut laid_out) = (0, 0);
for _ in 0..4000 {
let mut block = block();
for _ in 0..1 + (rng.unit() * 3.0) as usize {
let i = (rng.unit() * 12.0) as usize % 12;
let n = if rng.unit() < 0.3 { NUMBERS[(rng.unit() * 9.0) as usize % 9] } else { (rng.unit() - 0.5) * 20.0 };
let cell = &mut block.cells[i];
match (rng.unit() * 8.0) as usize {
0 => block.size_mm.x += n,
1 => block.size_mm.y = n,
2 => block.outline_line_mm = n,
3 => {
if let CellKind::Text(text) = &mut cell.kind {
text.cap_mm += n;
}
}
4 => cell.label = Some(Label { text: "x".into(), cap_mm: n }),
k => cell.rect_mm[(k + i) % 4] += n,
}
}
if block.validate().is_err() {
continue;
}
accepted += 1;
let size = PaperSize::ALL[(rng.unit() * PaperSize::ALL.len() as f64) as usize % PaperSize::ALL.len()];
let orientation = if rng.unit() < 0.5 { Orientation::Landscape } else { Orientation::Portrait };
let sheet = if rng.unit() < 0.5 { iso(size, orientation) } else { filing(size, orientation) };
let line_mm = [0.25, 0.7, 2.0, 5.0, 10.0][(rng.unit() * 5.0) as usize % 5];
let sheet = SheetLayout { drawing_border_line_mm: line_mm, ..sheet };
let Ok(layout) = block.layout(&sheet) else { continue };
laid_out += 1;
let outline = layout.outline.inflated(1e-9);
let clear = sheet.drawing_border.inflated(-line_mm / 2.0 - PADDING_MM + 1e-9);
for (i, (cell, placed)) in block.cells.iter().zip(&layout.cells).enumerate() {
assert!(outline.contains(placed.rect.min) && outline.contains(placed.rect.max), "{} {:?}", cell.name, placed.rect);
assert!(layout.cells.iter().take(i).all(|other| !overlap(other.rect, placed.rect)), "{}", cell.name);
let inside = placed.rect.inflated(1e-9);
assert!(inside.contains(placed.value_box.min) && inside.contains(placed.value_box.max), "{}", cell.name);
let room = placed.value_box.size();
assert!(room.x > 0.0 && room.y > 0.0 && clear.contains(placed.value_box.min) && clear.contains(placed.value_box.max), "{}", cell.name);
if let Some(at) = placed.label_at {
assert!(inside.contains(at), "{}", cell.name);
}
if let CellKind::Text(text) = &cell.kind {
let (_, warning) = place_lines(placed.value_box, 1, text.cap_mm, text.cap_mm * 1.6, text.align, false).unwrap();
assert_eq!(warning, None, "{}", cell.name);
}
}
}
// Fewer lay out than accepted: about two in five meet a 5 or 10 mm border line, which leaves most blocks no room.
assert!(accepted > 400 && laid_out > 200, "{accepted} accepted, {laid_out} laid out");
}
+16 -1
View File
@@ -5,6 +5,7 @@ use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
mod drawing;
mod native_cache;
mod thumbnail;
pub use native_cache::read_design_geometry;
@@ -265,10 +266,16 @@ fn write_container(
value.as_object_mut().ok_or_else(|| invalid("canvas metadata"))?.insert("image_blob".into(), blob);
canvases.push(value);
}
let metadata = replace(doc, fields([("features", Value::Array(features)), ("canvases", Value::Array(canvases))])).map_err(invalid)?;
let replacements = fields([("features", Value::Array(features)), ("canvases", Value::Array(canvases)), ("drawings", Value::Null)]);
let mut metadata = replace(doc, replacements).map_err(invalid)?;
// The drawings have entries of their own: `document.json` is the same with them or without.
if let Some(o) = metadata.as_object_mut() {
o.remove("drawings");
}
let metadata = serde_json::to_vec(&metadata).map_err(invalid)?;
writer.part("document.json", &metadata, true)?;
writer.part("container.json", &serde_json::to_vec(&json!({"format": VERSION})).map_err(invalid)?, false)?;
drawing::write(&mut writer, doc)?;
// The model cache is optional: one that does not fit the container is left out, never a
// failed save.
let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata).ok()).filter(|(manifest, data)| {
@@ -428,6 +435,10 @@ impl<R: Read + Seek> PackageReader<R> {
}
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
}
/// Every entry's name (each was checked when the container was opened).
fn names(&self) -> Result<Vec<String>> {
self.zip.file_names().map(|name| name.map(|n| n.into_owned()).map_err(invalid)).collect()
}
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
self.limited_part(name, if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART })
}
@@ -669,6 +680,9 @@ fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Docume
// Migrations and all document validation run on the small parametric metadata.
// Binary arrays have their own equivalent numeric/index validation below.
let mut doc = Document::from_json(text)?;
if !doc.drawings.is_empty() {
return Err(invalid("ambiguous drawings in document.json and in their own entries"));
}
for (index, feature) in doc.features.iter_mut().enumerate() {
let value = metadata.get("features").and_then(|v| v.get(index)).ok_or_else(|| invalid("missing feature metadata"))?;
match &mut feature.kind {
@@ -701,6 +715,7 @@ fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Docume
}
canvas.data = solidcraft_doc::canvas::base64_encode(bytes);
}
doc.drawings = drawing::read(reader)?;
Ok(doc)
}
+48
View File
@@ -0,0 +1,48 @@
//! A design's drawings in its container (#3890): each drawing in its own entry,
//! `drawings/<id>.json` (Deflate, as `document.json`), its id as [`ids::encode`] writes it and its
//! text [`Drawing::to_json`]'s. `document.json` holds nothing of them, so its bytes, and the model
//! cache keyed to them, don't change when only a drawing does. An entry under `drawings/` that
//! isn't a drawing's, or whose record has another id, is damage: the design is refused.
use std::io::{Read, Seek};
use std::sync::Arc;
use solidcraft_doc::drawing::{self, Drawing, MAX_DRAWING_BYTES};
use solidcraft_doc::{Document, ids};
use super::{PackageReader, PackageWriter, invalid};
use crate::Result;
/// The folder of a container's drawing entries.
const FOLDER: &str = "drawings/";
/// Write each of the design's drawings to its entry.
pub(super) fn write(writer: &mut PackageWriter, doc: &Document) -> Result<()> {
drawing::check_in_design(&doc.drawings)?;
for d in &doc.drawings {
writer.part(&format!("{FOLDER}{}.json", ids::encode(d.id)), d.to_json()?.as_bytes(), true)?;
}
Ok(())
}
/// Read the container's drawings, in their order.
pub(super) fn read<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Vec<Arc<Drawing>>> {
let names: Vec<String> = reader.names()?.into_iter().filter(|n| n.starts_with(FOLDER)).collect();
drawing::check_count(names.len()).map_err(invalid)?;
let mut drawings = Vec::with_capacity(names.len());
for name in names {
let id = name.strip_prefix(FOLDER).and_then(|n| n.strip_suffix(".json")).and_then(ids::decode);
let id = id.ok_or_else(|| invalid(format!("`{name}` is not a drawing's entry")))?;
let bytes = reader.limited_part(&name, MAX_DRAWING_BYTES)?;
let d = Drawing::from_json(std::str::from_utf8(&bytes).map_err(invalid)?).map_err(|e| invalid(format!("`{name}`: {e}")))?;
if d.id != id {
return Err(invalid(format!("`{name}` holds the drawing {}", ids::encode(d.id))));
}
drawings.push(d);
}
drawing::in_design(drawings).map_err(invalid)
}
#[cfg(test)]
#[path = "drawing_tests.rs"]
mod tests;
+262
View File
@@ -0,0 +1,262 @@
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Write};
use serde_json::{Value, json};
use solidcraft_doc::drawing::{MAX_DRAWINGS, Source};
use solidcraft_doc::format::DRAWING;
use solidcraft_doc::ids::IdSource;
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile, Orientation, PaperSize};
use super::*;
use crate::{read_design, write_design, write_design_container};
/// A design with two drawings of it, each on one A3 sheet, in their order.
fn plate() -> Document {
let mut ids = IdSource::seeded(3890);
let mut doc = Document::new("Plate");
for name in ["Assembly", "Details"] {
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
let taken: BTreeSet<u64> = doc.drawings.iter().map(|d| d.id).collect();
let mut d = Drawing::new(name, "en-GB", "Sheet 1", a3, Some(DrawingBorderProfile::iso()), &taken, &mut ids).unwrap();
let id = d.allocate_id(&mut ids).unwrap();
d.sources.push(Source { id });
doc.drawings.push(Arc::new(d));
}
doc
}
/// A container's parts by name.
fn parts(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
(0..zip.len())
.map(|i| {
let mut entry = zip.by_index(i).unwrap();
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).unwrap();
(entry.name().unwrap().into_owned(), bytes)
})
.collect()
}
/// A container of these parts, as a hostile or older tool might write it.
fn container(parts: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in parts {
zip.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// The entry of the drawing with this id.
fn entry(id: u64) -> String {
format!("drawings/{}.json", ids::encode(id))
}
/// A design's drawings round-trip through both forms byte for byte. The container keeps each in its
/// own entry, holding the drawing's own text, and its `document.json` and `container.json` are the
/// same bytes with drawings or without: a drawing edit never makes the model cache, keyed to
/// `document.json`, stale.
#[test]
fn a_designs_drawings_round_trip_through_both_forms_byte_for_byte() {
let doc = plate();
let bytes = write_design_container(&doc).unwrap();
let read = read_design(&bytes).unwrap();
assert_eq!(read.drawings, doc.drawings);
assert_eq!(write_design_container(&read).unwrap(), bytes, "save, open, save: the same bytes");
let inside = parts(&bytes);
let entries: Vec<_> = inside.keys().filter(|n| n.starts_with("drawings/")).cloned().collect();
assert_eq!(entries, doc.drawings.iter().map(|d| entry(d.id)).collect::<BTreeSet<_>>().into_iter().collect::<Vec<_>>());
for d in &doc.drawings {
assert_eq!(inside[&entry(d.id)], d.to_json().unwrap().as_bytes());
}
let without = parts(&write_design_container(&Document { drawings: Vec::new(), ..doc.clone() }).unwrap());
for part in ["document.json", "container.json"] {
assert_eq!(inside[part], without[part], "{part} is the same with drawings or without");
}
let json = write_design(&doc);
let read = read_design(&json).unwrap();
assert_eq!(read.drawings, doc.drawings);
assert_eq!(write_design(&read), json, "the plain form too");
}
/// What a container refuses in its drawings, each time the whole design: an entry under
/// `drawings/` that isn't a drawing's, one whose record has another id, a damaged drawing, one a
/// newer build saved, drawings inline in `document.json` beside the entries, and more entries than
/// a design may hold, counted before any is read.
#[test]
fn a_container_refuses_drawing_entries_it_cant_take() {
let doc = plate();
let good = parts(&write_design_container(&doc).unwrap());
let [first, second] = [0, 1].map(|i| doc.drawings[i].id);
let text = |i: usize| String::from_utf8(good[&entry(doc.drawings[i].id)].clone()).unwrap();
let refusal = |edit: &dyn Fn(&mut BTreeMap<String, Vec<u8>>)| {
let mut parts = good.clone();
edit(&mut parts);
read_design(&container(&parts)).map_or_else(|e| e.to_string(), |d| format!("read {}", d.drawings.len()))
};
assert_eq!(refusal(&|_| {}), "read 2");
for name in ["drawings/0000000000000.json", "drawings/ABCDEFGHJKMNP.json", "drawings/x/0000000000001.json", "drawings/0000000000001.svg"] {
let why = refusal(&|p| {
p.insert(name.into(), b"{}".to_vec());
});
assert_eq!(why, format!("design container: `{name}` is not a drawing's entry"));
}
let moved = refusal(&|p| {
let bytes = p.remove(&entry(first)).unwrap();
p.insert(entry(7), bytes);
});
assert_eq!(moved, format!("design container: `{}` holds the drawing {}", entry(7), ids::encode(first)));
let damaged = refusal(&|p| {
p.insert(entry(second), text(1).replace("en-GB", "e").into_bytes());
});
assert!(damaged.starts_with(&format!("design container: `{}`: damaged drawing: the language", entry(second))), "{damaged}");
let newer = DRAWING.format_string(DRAWING.current() + 1);
let why = refusal(&|p| {
p.insert(entry(second), text(1).replace(&DRAWING.format_string(DRAWING.current()), &newer).into_bytes());
});
assert!(why.contains("this drawing was saved by a newer SolidCraft"), "{why}");
let inline = refusal(&|p| {
let mut metadata: Value = serde_json::from_slice(&p["document.json"]).unwrap();
metadata["drawings"] = json!([serde_json::from_str::<Value>(&text(0)).unwrap()]);
p.insert("document.json".into(), serde_json::to_vec(&metadata).unwrap());
});
assert_eq!(inline, "design container: ambiguous drawings in document.json and in their own entries");
let over = refusal(&|p| {
for id in 1..=MAX_DRAWINGS as u64 {
p.insert(entry(id), Vec::new());
}
});
assert_eq!(over, format!("design container: a design holds at most {MAX_DRAWINGS} drawings"));
}
/// A design whose drawings break the rules isn't saved: two with one id would share one entry, and
/// one of them would be lost.
#[test]
fn a_design_with_two_drawings_of_one_id_is_not_saved() {
let mut doc = plate();
let first = doc.drawings[0].clone();
doc.drawings[1] = Arc::new(Drawing { name: "Details".into(), ..(*first).clone() });
let why = write_design_container(&doc).unwrap_err().to_string();
assert_eq!(why, format!("two drawings have the id {}", ids::encode(first.id)));
}
// W38a acceptance through the real design container, not only Drawing JSON.
fn design_with_sheet_sketches() -> Document {
use solidcraft_doc::drawing::{SheetSketch, Units};
use solidcraft_doc::sketch::{ConstraintKind, PointExprs, Sketch};
use solidcraft_geom::Vec2;
let mut doc = plate();
let mut ids = IdSource::seeded(7082);
let shared_id = doc.drawings[0].allocate_id(&mut ids).unwrap();
for (i, drawing) in doc.drawings.iter_mut().enumerate() {
let d = Arc::make_mut(drawing);
let mut sketch = Sketch::new();
let inset = 15.0 + i as f64;
for (name, xy) in [("p0", (inset, 10.0)), ("p1", (inset + 40.0, 10.0)), ("p2", (inset + 40.0, 30.0)), ("p3", (inset, 30.0))] {
sketch.add_point(Vec2::new(xy.0, xy.1), Some(name)).unwrap();
}
for j in 0..4 {
sketch.add_line_pts(j + 1, (j + 1) % 4 + 1, Some(&format!("l{j}"))).unwrap();
}
sketch.add_constraint(ConstraintKind::Length { l: 0, value: 40.0 }, Some("Width".into())).unwrap();
sketch.points[1].exprs = Some(Box::new(PointExprs { x: Some("Inset + 5 mm".into()), y: None }));
d.units = Units::Fixed { unit: "in".into() };
// Identical sketch ids in different drawings are legal: the namespace
// is drawing-local, just as the curve/point ids are sketch-local.
d.sketches.push(Arc::new(SheetSketch { id: shared_id, sheet: d.sheets[0].id, name: format!("Outline {i}"), sketch }));
d.validate().unwrap();
}
doc
}
#[test]
fn sheet_sketches_round_trip_through_real_container_and_plain_design() {
let source = design_with_sheet_sketches();
let container_bytes = write_design_container(&source).unwrap();
let plain_bytes = write_design(&source);
for bytes in [&container_bytes, &plain_bytes] {
let reopened = read_design(bytes).unwrap();
assert_eq!(reopened.drawings, source.drawings);
for (i, d) in reopened.drawings.iter().enumerate() {
let record = &d.sketches[0];
assert_eq!(record.sheet, d.sheets[0].id);
assert_eq!(record.name, format!("Outline {i}"));
assert_eq!(record.sketch.points[1].pos.x, 15.0 + i as f64);
assert_eq!(record.sketch.points[2].pos.x - record.sketch.points[1].pos.x, 40.0);
assert_eq!(record.sketch.points[3].pos.y - record.sketch.points[2].pos.y, 20.0);
assert_eq!(record.sketch.points[1].exprs.as_ref().unwrap().x.as_deref(), Some("Inset + 5 mm"));
assert_eq!(record.sketch.constraints[0].param.as_deref(), Some("Width"));
assert_eq!(d.sheet_sketch(record.id).unwrap().name, format!("Outline {i}"));
}
assert_eq!(write_design_container(&reopened).unwrap(), container_bytes);
assert_eq!(write_design(&reopened), plain_bytes);
}
let entries = parts(&container_bytes);
for d in &source.drawings {
assert_eq!(entries[&entry(d.id)], d.to_json().unwrap().as_bytes());
}
}
#[test]
fn a_sheet_sketch_change_does_not_rewrite_model_or_another_drawings_entry() {
let source = design_with_sheet_sketches();
let before = parts(&write_design_container(&source).unwrap());
let mut changed = source.clone();
let drawing = Arc::make_mut(&mut changed.drawings[0]);
let sketch = Arc::make_mut(&mut drawing.sketches[0]);
sketch.name = "Revised outline".into();
sketch.sketch.points[1].exprs.as_mut().unwrap().x = Some("Inset + 6 mm".into());
drawing.validate().unwrap();
let after = parts(&write_design_container(&changed).unwrap());
assert_eq!(before.keys().collect::<Vec<_>>(), after.keys().collect::<Vec<_>>());
let own_entry = entry(source.drawings[0].id);
for (name, bytes) in &before {
if *name == own_entry {
assert_ne!(bytes, &after[name]);
} else {
assert_eq!(bytes, &after[name], "a sketch edit rewrote {name}");
}
}
assert_eq!(after["document.json"], before["document.json"]);
assert_eq!(after["container.json"], before["container.json"]);
let reopened = read_design(&write_design_container(&changed).unwrap()).unwrap();
assert_eq!(reopened.drawings, changed.drawings);
assert_eq!(source.drawings[0].sketches[0].name, "Outline 0", "copy-on-write changed the original");
}
#[test]
fn a_bad_sheet_sketch_refuses_the_whole_container_without_owner_fallback() {
let source = design_with_sheet_sketches();
let original = write_design_container(&source).unwrap();
let good_parts = parts(&original);
let own_entry = entry(source.drawings[0].id);
let good: Value = serde_json::from_slice(&good_parts[&own_entry]).unwrap();
let foreign_sheet = source.drawings[1].sheets[0].id;
for (pointer, value) in [
("/sketches/0/sheet", json!(ids::encode(foreign_sheet))),
("/sketches/0/id", json!(ids::encode(source.drawings[0].id))),
("/sketches/0/sketch/curves/0/a", json!(99_999)),
("/sketches/0/sketch/points/1/id", json!("origin")),
("/sketches/0/sketch/points/1/pos/0", json!(1e100)),
("/sketches/0/sketch/links", json!([null])),
] {
let mut record = good.clone();
if pointer == "/sketches/0/sketch/links" {
// Empty links are omitted on write; a hostile file can add them.
record["sketches"][0]["sketch"]["links"] = value;
} else {
*record.pointer_mut(pointer).unwrap() = value;
}
let mut corrupt = good_parts.clone();
corrupt.insert(own_entry.clone(), serde_json::to_vec(&record).unwrap());
let error = read_design(&container(&corrupt)).unwrap_err().to_string();
assert!(error.contains(&own_entry), "missing drawing context for {pointer}: {error}");
let mut inline: Value = serde_json::from_slice(&write_design(&source)).unwrap();
inline["drawings"][0] = record;
assert!(read_design(&serde_json::to_vec(&inline).unwrap()).is_err(), "plain JSON accepted {pointer}");
}
assert_eq!(read_design(&original).unwrap().drawings, source.drawings);
assert_eq!(parts(&original), good_parts, "hostile-input checks changed their source");
}
+1
View File
@@ -21,6 +21,7 @@ mod legacy_dir;
mod mutate;
mod native_cad;
pub use native_cad::{NATIVE_CAD_EXTS, is_native_cad_path, native_cad_to_step};
pub mod paper;
pub mod safe_write;
mod sketch2d;
mod svg;
+50
View File
@@ -525,6 +525,56 @@ outer
survives("design JSON", &mutations(&json, true, 0xde6), usize::MAX, |b| crate::read_design(b).ok().map(|d| d.features.len()));
}
/// A design with two drawings, as a container and as plain JSON: every mutation gives an error
/// or a design, never a panic, and never more drawings than a design may hold.
#[test]
fn designs_with_drawings_survive_mutation() {
use solidcraft_doc::drawing::{Drawing, MAX_DRAWINGS, Source};
use solidcraft_geom::drawing_sheet::{DrawingBorderProfile, Orientation, PaperSize, Sheet};
let mut ids = solidcraft_doc::ids::IdSource::seeded(0xd4a);
let mut doc = solidcraft_doc::Document::new("Fuzz");
for name in ["Assembly", "Details"] {
let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape);
let taken = doc.drawings.iter().map(|d| d.id).collect();
let mut drawing = Drawing::new(name, "en", "Sheet 1", a3, Some(DrawingBorderProfile::iso()), &taken, &mut ids).unwrap();
let id = drawing.allocate_id(&mut ids).unwrap();
drawing.sources.push(Source { id });
doc.drawings.push(drawing.into());
}
let container = crate::write_design_container(&doc).unwrap();
assert_eq!(crate::read_design(&container).unwrap().drawings, doc.drawings);
survives("design container with drawings", &mutations(&container, false, 0xd4b), MAX_DRAWINGS, |b| {
crate::read_design(b).ok().map(|d| d.drawings.len())
});
let json = crate::write_design(&doc);
assert_eq!(crate::read_design(&json).unwrap().drawings, doc.drawings);
survives("design JSON with drawings", &mutations(&json, true, 0xd4c), MAX_DRAWINGS, |b| crate::read_design(b).ok().map(|d| d.drawings.len()));
}
/// A paper scene's JSON, mutated, read, prepared and written as SVG: an error or a document,
/// never a panic.
#[test]
fn paper_scenes_survive_mutation_into_svg() {
use solidcraft_geom::paper::{Group, Item, ObjectKind, Scene};
use solidcraft_geom::{Loop2, Region2, Seg2, Vec2};
let p = Vec2::new;
let border = Loop2::polygon(&[p(20.0, 10.0), p(410.0, 10.0), p(410.0, 287.0), p(20.0, 287.0)]);
let items = vec![
Item::Stroke { path: border.segs.clone(), closed: true, width_mm: 0.7 },
Item::Stroke { path: vec![Seg2::Arc { center: p(50.0, 50.0), radius: 10.0, start: 0.0, sweep: 4.0 }], closed: false, width_mm: 0.35 },
Item::Fill { region: Region2 { outer: border, holes: vec![] } },
];
let scene = Scene { size_mm: p(420.0, 297.0), groups: vec![Group { object: 7, kind: ObjectKind::DrawingBorder, items }] };
let json = serde_json::to_vec(&scene).unwrap();
let write = |b: &[u8]| -> Option<usize> {
let scene: Scene = serde_json::from_slice(b).ok()?;
let prepared = scene.prepare().ok()?;
crate::paper::paper_svg(&prepared).ok().map(|(bytes, _)| bytes.len())
};
assert!(write(&json).is_some());
survives("paper scene to SVG", &mutations(&json, true, 0x5a3), usize::MAX, write);
}
/// Standard base64 (RFC 4648) of `bytes`, for a data URI.
fn base64(bytes: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+5
View File
@@ -0,0 +1,5 @@
//! A sheet's paper scene (`geom::paper`) in a file: SVG first (A3, #4890); DXF, PNG and PDF follow.
mod svg;
pub use svg::{SvgReport, paper_svg};
+242
View File
@@ -0,0 +1,242 @@
//! A paper scene as SVG (#4890, A3): one user unit a millimetre, the paper's size in mm, and the
//! scene's own curves where SVG has them.
//!
//! - **Numbers** are whole nanometres, written as millimetres with up to six decimals: no exponent, no "-0",
//! nothing that depends on the locale. They are also the writer's state, so a joint is written
//! once and a closed path ends exactly on its first point.
//! - **y is flipped** by writing the page height less y, so the file's numbers are page
//! coordinates, with no transform. After the flip an anticlockwise arc turns the negative way,
//! so its sweep flag is 0.
//! - **Curves:** lines `L`, circular arcs `A` in pieces of at most a quarter turn (a half turn's
//! centre is ill-conditioned once its ends are rounded), cubics `C`, parabolas `Q`. Ellipses,
//! hyperbolas and Béziers above degree 3 aren't written yet (A3b): a scene with one is refused.
//! - **Paint:** strokes `stroke-width`, with butt caps, miter joins and a miter limit of 4 on the
//! root; fills black, even-odd. One `<path>` per item, so each stays one object in an editor.
//! - **Groups** are `<g data-object="…" data-kind="…">`: object ids repeat and start with a digit,
//! so they can't be XML ids. No background: the page stays transparent, as cutters want.
//!
//! What the grid can't express is counted, never an error: a width under half a nanometre is
//! written as one, and a piece ending within 10 nanometres of where the path is goes straight on
//! to the next.
use std::fmt::Write;
use solidcraft_geom::paper::{Item, Prepared, arc_pieces};
use solidcraft_geom::{Region2, Seg2, Vec2};
use crate::{IoError, MAX_DRAWING_BYTES, Result};
/// The largest number written, in millimetres either way: far past any scene's 2e5.
const MAX_NUMBER_MM: f64 = 9e9;
/// Pieces ending this many grid steps or fewer from where the path is are merged (1e-5 mm).
const SLIVER_STEPS: i64 = 10;
/// What writing a scene did that the scene didn't say.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct SvgReport {
/// Widths under half a nanometre, written as one nanometre.
pub widths_snapped: usize,
/// Pieces too short for the nanometre grid, merged into the next.
pub pieces_merged: usize,
}
/// A scene's SVG document, and what writing it did.
pub fn paper_svg(scene: &Prepared) -> Result<(Vec<u8>, SvgReport)> {
let mut w = Writer { out: String::new(), height: nano(scene.size_mm().y)?, report: SvgReport::default(), at: (0, 0), first: (0, 0) };
let (width, height) = (number(nano(scene.size_mm().x)?), number(w.height));
w.out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let _ = writeln!(
w.out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{width}mm\" height=\"{height}mm\" viewBox=\"0 0 {width} {height}\" \
fill=\"none\" stroke=\"#000\" stroke-linecap=\"butt\" stroke-linejoin=\"miter\" stroke-miterlimit=\"4\">"
);
for group in scene.groups() {
let kind = serde_json::to_value(group.kind).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
let _ = writeln!(w.out, "<g data-object=\"{}\" data-kind=\"{kind}\">", group.object);
for item in &group.items {
match item {
Item::Stroke { path, closed, width_mm } => w.stroke(path, *closed, *width_mm)?,
Item::Fill { region } => w.fill(region)?,
}
if w.out.len() > MAX_DRAWING_BYTES {
return Err(IoError::Invalid(format!("the SVG would be over {} MiB", MAX_DRAWING_BYTES >> 20)));
}
}
w.out.push_str("</g>\n");
}
w.out.push_str("</svg>\n");
Ok((w.out.into_bytes(), w.report))
}
/// A value in millimetres as whole nanometres.
fn nano(v: f64) -> Result<i64> {
let n = (v * 1e6).round();
if n.abs() <= MAX_NUMBER_MM * 1e6 { Ok(n as i64) } else { Err(IoError::Invalid("a number too large (or not a number) for the SVG".into())) }
}
/// Nanometres written as millimetres onto `out`: the integer part, then up to six decimals
/// without trailing zeros.
fn push_number(out: &mut String, n: i64) {
let (sign, n) = if n < 0 { ("-", n.unsigned_abs()) } else { ("", n.unsigned_abs()) };
let (whole, mut part, mut digits) = (n / 1_000_000, n % 1_000_000, 6);
let _ = write!(out, "{sign}{whole}");
if part != 0 {
while part % 10 == 0 {
(part, digits) = (part / 10, digits - 1);
}
let _ = write!(out, ".{part:0digits$}");
}
}
/// [`push_number`] as its own text, for the numbers written once per document or arc.
fn number(n: i64) -> String {
let mut text = String::new();
push_number(&mut text, n);
text
}
struct Writer {
out: String,
/// The page height, in nanometres: y is written as this less y.
height: i64,
report: SvgReport,
/// Where the path is, and where its subpath started, on the grid.
at: (i64, i64),
first: (i64, i64),
}
impl Writer {
/// A scene point on the page's grid.
fn grid(&self, p: Vec2) -> Result<(i64, i64)> {
Ok((nano(p.x)?, self.height - nano(p.y)?))
}
fn point(&mut self, (x, y): (i64, i64)) {
push_number(&mut self.out, x);
self.out.push(' ');
push_number(&mut self.out, y);
}
/// Starts a subpath at `p`.
fn move_to(&mut self, p: Vec2) -> Result<()> {
let at = self.grid(p)?;
self.out.push('M');
self.point(at);
(self.at, self.first) = (at, at);
Ok(())
}
/// Whether a piece ending at `end` is too short to write: it is merged into the next. A path's
/// last piece (`keep`) has no next, so it is written unless it has no length: an open path
/// ends where it ends, a closed one on its first point exactly.
fn merged(&mut self, end: (i64, i64), keep: bool) -> bool {
if keep {
return end == self.at;
}
let (dx, dy) = (end.0 - self.at.0, end.1 - self.at.1);
let short = dx.abs() <= SLIVER_STEPS && dy.abs() <= SLIVER_STEPS && dx * dx + dy * dy <= SLIVER_STEPS * SLIVER_STEPS;
if short {
self.report.pieces_merged += 1;
}
short
}
/// One piece, from where the path is: `closing` makes its end the subpath's first point, and
/// `keep` marks the path's last piece ([`Writer::merged`]).
fn piece(&mut self, seg: &Seg2, closing: bool, keep: bool) -> Result<()> {
let end = if closing { self.first } else { self.grid(seg.end())? };
match *seg {
Seg2::Line { .. } => {
if !self.merged(end, keep) {
self.out.push('L');
self.point(end);
self.at = end;
}
}
Seg2::Arc { center, radius, start, sweep } => {
let r = number(nano(radius)?);
let flag = u8::from(sweep < 0.0);
let mut ends = arc_pieces(center, radius, start, sweep).peekable();
while let Some(p) = ends.next() {
let final_piece = ends.peek().is_none();
let to = if final_piece { end } else { self.grid(p)? };
if !self.merged(to, keep && final_piece) {
let _ = write!(self.out, "A{r} {r} 0 0 {flag} ");
self.point(to);
self.at = to;
}
}
}
Seg2::Cubic { p1, p2, .. } => {
let (c1, c2) = (self.grid(p1)?, self.grid(p2)?);
self.out.push('C');
self.point(c1);
self.out.push(' ');
self.point(c2);
self.out.push(' ');
self.point(end);
self.at = end;
}
Seg2::Conic { apex, w, .. } if (w - 1.0).abs() <= 1e-12 => {
let c = self.grid(apex)?;
self.out.push('Q');
self.point(c);
self.out.push(' ');
self.point(end);
self.at = end;
}
Seg2::Conic { .. } | Seg2::Bezier { .. } => {
return Err(IoError::Invalid("not supported yet: SVG of ellipses, hyperbolas and Béziers above degree 3 (#4890, A3b)".into()));
}
}
Ok(())
}
/// A path's pieces after its `M`: a closed one ends exactly on its first point, a line back to
/// it as `Z`.
fn pieces(&mut self, path: &[Seg2], closed: bool) -> Result<()> {
let Some((last, body)) = path.split_last() else { return Ok(()) };
for seg in body {
self.piece(seg, false, false)?;
}
if !closed {
return self.piece(last, false, true);
}
if !matches!(last, Seg2::Line { .. }) {
self.piece(last, true, true)?;
}
self.out.push('Z');
Ok(())
}
fn stroke(&mut self, path: &[Seg2], closed: bool, width_mm: f64) -> Result<()> {
let Some(first) = path.first() else { return Ok(()) };
let mut width = nano(width_mm)?;
if width == 0 {
width = 1;
self.report.widths_snapped += 1;
}
self.out.push_str("<path stroke-width=\"");
push_number(&mut self.out, width);
self.out.push_str("\" d=\"");
self.move_to(first.start())?;
self.pieces(path, closed)?;
self.out.push_str("\"/>\n");
Ok(())
}
fn fill(&mut self, region: &Region2) -> Result<()> {
self.out.push_str("<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"");
for l in region.loops() {
let Some(first) = l.segs.first() else { continue };
self.move_to(first.start())?;
self.pieces(&l.segs, true)?;
}
self.out.push_str("\"/>\n");
Ok(())
}
}
#[cfg(test)]
#[path = "svg_tests.rs"]
mod tests;
+165
View File
@@ -0,0 +1,165 @@
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::paper::{Group, ObjectKind, Scene};
use solidcraft_geom::{Loop2, Region2};
use super::*;
use crate::{Geom2, read_svg};
fn p(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
fn line(a: Vec2, b: Vec2) -> Seg2 {
Seg2::Line { a, b }
}
fn stroke(path: Vec<Seg2>, closed: bool, width_mm: f64) -> Item {
Item::Stroke { path, closed, width_mm }
}
/// Anticlockwise from `min`.
fn rectangle(min: Vec2, max: Vec2) -> Loop2 {
Loop2::polygon(&[min, p(max.x, min.y), max, p(min.x, max.y)])
}
/// An A3 landscape sheet with one object's items.
fn sheet(items: Vec<Item>) -> Scene {
Scene { size_mm: p(420.0, 297.0), groups: vec![Group { object: 1, kind: ObjectKind::DrawingBorder, items }] }
}
fn svg(scene: &Scene) -> (String, SvgReport) {
let (bytes, report) = paper_svg(&scene.prepare().unwrap()).unwrap();
(String::from_utf8(bytes).unwrap(), report)
}
/// The `d` of the document's first path.
fn first_d(scene: &Scene) -> String {
let (text, _) = svg(scene);
let start = text.find(" d=\"").unwrap() + 4;
text[start..].split('"').next().unwrap().to_string()
}
fn arc(center: Vec2, radius: f64, start: f64, sweep: f64) -> Seg2 {
Seg2::Arc { center, radius, start, sweep }
}
/// The plan's A3 sheet, the whole document: a drawing border closed by `Z`, two trimming fills.
#[test]
fn an_a3_drawing_border_is_this_document() {
let scene = sheet(vec![
stroke(rectangle(p(20.0, 10.0), p(410.0, 287.0)).segs, true, 0.7),
Item::Fill { region: Region2 { outer: rectangle(p(0.0, 0.0), p(10.0, 5.0)), holes: vec![] } },
Item::Fill { region: Region2 { outer: rectangle(p(0.0, 0.0), p(5.0, 10.0)), holes: vec![] } },
]);
let (text, report) = svg(&scene);
let expected = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"420mm\" height=\"297mm\" viewBox=\"0 0 420 297\" fill=\"none\" stroke=\"#000\" \
stroke-linecap=\"butt\" stroke-linejoin=\"miter\" stroke-miterlimit=\"4\">\n\
<g data-object=\"1\" data-kind=\"drawing_border\">\n\
<path stroke-width=\"0.7\" d=\"M20 287L410 287L410 10L20 10Z\"/>\n\
<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"M0 297L10 297L10 292L0 292Z\"/>\n\
<path fill=\"#000\" stroke=\"none\" fill-rule=\"evenodd\" d=\"M0 297L5 297L5 287L0 287Z\"/>\n\
</g>\n\
</svg>\n";
assert_eq!(text, expected);
assert_eq!(report, SvgReport::default());
assert_eq!(svg(&scene).0, text, "the same scene gives the same bytes");
}
/// Arcs: anticlockwise on paper is sweep flag 0 after the flip, clockwise 1; at most a quarter turn
/// a piece, so a 270° arc is three and a whole turn four, large-arc never 1.
#[test]
fn arcs_are_written_in_quarter_turns_with_the_flipped_sweep() {
let c = p(50.0, 50.0);
let open = |seg: Seg2| sheet(vec![stroke(vec![seg], false, 0.35)]);
assert_eq!(first_d(&open(arc(c, 10.0, 0.0, FRAC_PI_2))), "M60 247A10 10 0 0 0 50 237");
assert_eq!(first_d(&open(arc(c, 10.0, 0.0, 1.5 * PI))), "M60 247A10 10 0 0 0 50 237A10 10 0 0 0 40 247A10 10 0 0 0 50 257");
assert_eq!(first_d(&open(arc(c, 10.0, FRAC_PI_2, -FRAC_PI_2))), "M50 237A10 10 0 0 1 60 247", "clockwise: sweep flag 1");
let circle = first_d(&sheet(vec![stroke(vec![arc(c, 10.0, 0.0, TAU)], true, 0.35)]));
assert_eq!(circle, "M60 247A10 10 0 0 0 50 237A10 10 0 0 0 40 247A10 10 0 0 0 50 257A10 10 0 0 0 60 247Z");
let halves = first_d(&sheet(vec![stroke(Loop2::circle(c, 10.0).segs, true, 0.35)]));
assert_eq!(halves, circle, "a circle of two halves is the same four quarters");
// Just over a quarter is two pieces; a quarter and a hair (the 1e-9 margin) is one.
assert_eq!(first_d(&open(arc(c, 10.0, 0.0, FRAC_PI_2 + 1e-12))).matches('A').count(), 1);
assert_eq!(first_d(&open(arc(c, 10.0, 0.0, FRAC_PI_2 + 1e-6))).matches('A').count(), 2);
}
/// Cubics as `C`, parabolas as `Q`; ellipses, hyperbolas and higher Béziers refused until A3b.
#[test]
fn cubics_and_parabolas_are_written_and_other_conics_refused() {
let cubic = Seg2::Cubic { p0: p(0.0, 0.0), p1: p(10.0, 20.0), p2: p(30.0, 20.0), p3: p(40.0, 0.0) };
assert_eq!(first_d(&sheet(vec![stroke(vec![cubic], false, 0.25)])), "M0 297C10 277 30 277 40 297");
let parabola = Seg2::Conic { a: p(0.0, 0.0), apex: p(10.0, 10.0), b: p(20.0, 0.0), w: 1.0 };
assert_eq!(first_d(&sheet(vec![stroke(vec![parabola], false, 0.25)])), "M0 297Q10 287 20 297");
for w in [0.5, 2.0] {
let conic = Seg2::Conic { a: p(0.0, 0.0), apex: p(10.0, 10.0), b: p(20.0, 0.0), w };
let scene = sheet(vec![stroke(vec![conic], false, 0.25)]);
let e = paper_svg(&scene.prepare().unwrap()).err().map(|e| e.to_string()).unwrap();
assert!(e.contains("not supported yet"), "{w}: {e}");
}
}
/// Numbers are whole nanometres: no exponent, no "-0", trailing zeros trimmed; a width under half
/// a nanometre is written as one and reported.
#[test]
fn numbers_are_whole_nanometres() {
for (n, text) in [(0, "0"), (1, "0.000001"), (-1, "-0.000001"), (1_500_000, "1.5"), (-420_000_000, "-420"), (123_456_789, "123.456789")] {
assert_eq!(number(n), text);
}
assert_eq!((nano(-0.0).unwrap(), nano(4e-7).unwrap(), nano(6e-7).unwrap()), (0, 0, 1));
assert!(nano(f64::NAN).is_err() && nano(1e10).is_err());
let thin = sheet(vec![stroke(vec![line(p(0.0, 0.0), p(1.0, 0.0))], false, 1e-9)]);
let (text, report) = svg(&thin);
assert!(text.contains("stroke-width=\"0.000001\""), "{text}");
assert_eq!(report.widths_snapped, 1);
}
/// A closed path ends exactly on its first point, even when its end and start round apart; a
/// sliver of a piece is merged into the next and reported; an open path's last piece is kept
/// however short, since nothing follows it.
#[test]
fn joints_and_closure_are_exact_on_the_grid() {
// The start rounds to 1 nanometre, the end (2e-10 away) to 0: the last curve ends on the start.
let (s, e) = (p(5e-7 + 1e-10, 0.0), p(5e-7 - 1e-10, 0.0));
let path =
vec![line(s, p(10.0, 0.0)), line(p(10.0, 0.0), p(10.0, 10.0)), Seg2::Cubic { p0: p(10.0, 10.0), p1: p(5.0, 10.0), p2: p(0.0, 5.0), p3: e }];
let d = first_d(&sheet(vec![stroke(path, true, 0.35)]));
assert_eq!(d, "M0.000001 297L10 297L10 287C5 287 0 292 0.000001 297Z");
// A 1e-9 mm line between two lines: merged, the path straight on to the next point.
let path = vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(10.0, 1e-9)), line(p(10.0, 1e-9), p(15.0, 8.66))];
let (text, report) = svg(&sheet(vec![stroke(path, false, 0.35)]));
assert!(text.contains("d=\"M0 297L10 297L15 288.34\""), "{text}");
assert_eq!(report.pieces_merged, 1);
// A 5 nanometre last piece of an open path stays; one that rounds to no length doesn't.
let path = vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(10.000005, 0.0))];
let (text, report) = svg(&sheet(vec![stroke(path, false, 0.35)]));
assert!(text.contains("d=\"M0 297L10 297L10.000005 297\""), "{text}");
assert_eq!(report.pieces_merged, 0);
let path = vec![line(p(0.0, 0.0), p(10.0, 0.0)), line(p(10.0, 0.0), p(10.0000001, 0.0))];
assert_eq!(first_d(&sheet(vec![stroke(path, false, 0.35)])), "M0 297L10 297");
// The same for an arc's last quarter: a 5 nanometre arc ending an open path is written.
let path = vec![line(p(0.0, 0.0), p(10.0, 0.0)), arc(p(10.0, 1.0), 1.0, -FRAC_PI_2, 5e-6)];
assert!(first_d(&sheet(vec![stroke(path, false, 0.35)])).starts_with("M0 297L10 297A1 1 0 0 0 10.000005 297"));
}
/// Our own SVG reader takes the file back: in millimetres, lines exactly (these have at most six
/// decimals), circular arcs as arcs. The reader's root transform puts the page's top at 0, so its
/// y is ours less the height.
#[test]
fn the_svg_reader_reads_it_back() {
let scene = sheet(vec![
stroke(vec![line(p(20.0, 10.0), p(410.0, 10.0)), line(p(410.0, 10.0), p(410.0, 287.0))], false, 0.7),
stroke(vec![arc(p(50.0, 50.0), 10.0, 0.0, FRAC_PI_2)], false, 0.35),
]);
let (text, _) = svg(&scene);
let read = read_svg(&text).unwrap();
let up = |v: Vec2| p(v.x, v.y + 297.0);
let lines: Vec<(Vec2, Vec2)> = read.iter().filter_map(|g| if let Geom2::Line(a, b) = g { Some((up(*a), up(*b))) } else { None }).collect();
assert_eq!(lines, [(p(20.0, 10.0), p(410.0, 10.0)), (p(410.0, 10.0), p(410.0, 287.0))]);
let arcs: Vec<(Vec2, Vec2, Vec2)> =
read.iter().filter_map(|g| if let Geom2::Arc { c, a, b } = g { Some((up(*c), up(*a), up(*b))) } else { None }).collect();
assert_eq!(arcs.len(), 1, "{read:?}");
let (c, a, b) = arcs[0];
assert!(c.dist(p(50.0, 50.0)) < 3e-6 && a.dist(p(60.0, 50.0)) < 3e-6 && b.dist(p(50.0, 60.0)) < 3e-6, "{:?}", arcs[0]);
}
+6 -1
View File
@@ -1270,7 +1270,8 @@ fn end_turn(app: &mut SolveApp) {
}
/// Merge the undo steps made since `start_doc` (at depth `start_undo`) into one, labelled `label`;
/// how many steps it took in (0: none were made, or the history no longer reaches that far).
/// how many steps it took in (0: none were made, the history no longer reaches that far, or they
/// changed several parts of the design, whose steps are each undone in their own part).
fn merge_undo(
undo: &mut Vec<solidcraft_engine::Snapshot>,
start_doc: &Arc<solidcraft_engine::doc::Document>,
@@ -1282,6 +1283,10 @@ fn merge_undo(
let Some(first) = (0..undo.len().min(start_undo + 1)).rev().find(|&i| undo.get(i).is_some_and(|s| Arc::ptr_eq(&s.doc, start_doc))) else {
return 0;
};
let part = undo.get(first).map(|s| s.part);
if undo.iter().skip(first).any(|s| Some(s.part) != part) {
return 0;
}
let steps = undo.len() - first;
undo.truncate(first + 1);
if let Some(s) = undo.get_mut(first) {
+7
View File
@@ -174,7 +174,9 @@ fn merging_finds_the_turns_start_when_the_history_was_trimmed() {
label: String::new(),
doc: d.clone(),
active_sketch: None,
active_sheet_sketch: None,
pending_moves: Default::default(),
part: None,
};
let docs: Vec<_> = (0..6).map(doc).collect();
// The turn began at depth 3 with docs[3]; the history then lost its oldest step.
@@ -186,6 +188,11 @@ fn merging_finds_the_turns_start_when_the_history_was_trimmed() {
let mut undo: Vec<_> = docs[1..6].iter().map(snap).collect();
assert_eq!(merge_undo(&mut undo, &doc(9), 3, "Agent"), 0);
assert_eq!(undo.len(), 5);
// A drawing's step among the model's: each part keeps its own steps.
let mut undo: Vec<_> = docs[1..6].iter().map(snap).collect();
undo[3].part = Some(7);
assert_eq!(merge_undo(&mut undo, &docs[3], 3, "Agent"), 0);
assert_eq!(undo.len(), 5);
}
/// People who don't want AI never see it: with "Show AI features" off there is no button, the
+17 -2
View File
@@ -329,7 +329,7 @@ revision, other tools' data under a reverse-DNS key (the same rules as the desig
properties), and free text properties such as a material grade or a finish. Every occurrence of
the component shares them, Paste New copies them, and they are saved with the component.
`component.metadata {component}` reads them and
`component.metadata.set {component, part_number?, description?, revision?, values?, properties?}`
`component.metadata.set {component, part_number?, description?, revision?, bom_type?, values?, properties?}`
changes them in one undo step (an empty value removes a field). The root component is the
design itself: its properties are `document.metadata`'s. Standard parts (`parts.insert`) get a
part number from their standard and size (`ISO 4762 M6x20`, or a bearing's designation) and a
@@ -351,8 +351,23 @@ description; **Change Part Size** renumbers them, keeping a field you changed yo
rows. `total_occurrences` counts the children too, so it is the same either way.
- `component` lists one subassembly's parts instead of the design's.
A component's **bill of materials type** (`bom_type`, a part property set with
`component.metadata.set {component, bom_type}`) decides how it counts in the assemblies it is
placed in. Without one it is `normal`.
| Type | For | In the assembly it is placed in |
|---|---|---|
| `normal` | Something you make | A row of its own; every occurrence adds to its quantity |
| `phantom` | Something just to group (a fastener set) | No row: what it holds counts as if it were placed there directly, in the same rows as the same parts placed there |
| `reference` | Something to use as a reference (a fixture shown for context) | Counted nowhere, and nothing inside it is counted either |
| `purchased` | Something you buy (a gearbox) | One row; nothing inside it is counted or listed |
| `inseparable` | Something made as one piece: welded, glued, riveted and so on (a welded frame) | One row; in parts only, the purchased parts inside it are listed too, and the rest of what it holds is listed in its own bill of materials |
Two components with the same part number share a row only when their types agree. A
component's own bill of materials (`component`) lists what it holds whatever its type.
Rows are sorted by part number (rows without one come last) and hold `item`, `part_number`,
`description`, `revision`, `count`, `definition` (the component names), `components` (their ids)
`description`, `revision`, `bom_type`, `count`, `definition` (the component names), `components` (their ids)
and `properties`, with `total_occurrences` beside them. `path` writes them to a `.csv` file
(the columns item, part_number, description, revision, count and definition, every field
quoted; children follow their row) or a `.json` file. The window lists the same rows, switches
+38
View File
@@ -46,6 +46,43 @@ An appearance sets colour and surface finish only. It has no effect on mass or s
- **Elsewhere:** appearance, visual material, colour.
- **For translators:** not the physical material.
### bill of materials
The list of the components placed in a design or in one component, with the quantity of each.
It lists every part at any depth, or only the direct children. Each component's *bill of
materials type* decides whether it has a row and whether what it holds is counted.
- **In SolidCraft:** `assembly.bom` ("Bill of Materials") and its window on the INSPECT panel;
each row's `item`, `part_number` and `count`; a component's `metadata` and `properties`
design-file keys, which `component.metadata.set` changes.
- **Don't use:** BOM in labels; parts list (for the bill of materials itself).
- **Elsewhere:** bill of materials, BOM, parts list.
- **For translators:** not a parts list on a drawing, which is one table that shows it.
### bill of materials type
The rule that decides whether a component has a row in a bill of materials, and whether what it
holds is counted there.
There are five:
- *normal*, something you make: a row; each occurrence adds one to its quantity.
- *phantom*, something just to group: no row; what it holds counts where it is placed.
- *reference*, something to use as a reference: no row, and nothing inside it counts.
- *purchased*, something you buy: one row; nothing inside it is counted or listed.
- *inseparable*, something made as one piece (welded, glued, riveted and so on): one row; of
what it holds, only the purchased components are listed, and only in a list of every part.
- **In SolidCraft:** the `bom_type` part property, which `component.metadata.set` changes and
`component.metadata` reads; the `bom_type` of each `assembly.bom` row.
- **Don't use:** BOM structure, item type, kind; made (for *normal*: it reads as "already
made").
- **Elsewhere:** BOM structure, BOM type.
- **For translators:** *normal* here is not the sketch line type, a direction or the display
quality (see *Words with more than one meaning*); *reference* is not a construction plane;
*phantom* is not a hidden or see-through display.
### body
One connected solid or surface shape that features create and change.
@@ -348,6 +385,7 @@ share a catalogue entry. Each meaning below gets its own term and, in time, its
| Normal | a sketch entity that isn't a construction entity | normal (line type) | the sketch palette's line type |
| | the direction perpendicular to a surface | normal (direction) | the ruled surface's Normal mode |
| | the middle display quality | normal (quality) | `ui.display_quality_normal` |
| | a component with a row of its own in a bill of materials | normal (bill of materials type) | `bom_type` |
Some translations show the clash today: Japanese and Chinese show a hole's flat bottom with the
word for "unfold" or "plane", Chinese shows "Round" as "fillet", and Chinese and Russian show the
+198
View File
@@ -0,0 +1,198 @@
# Hidden lines (W06a)
This is the geometry core of Drawing package W06 from the [published plan][plan] in
[#860][rfc]: [#2455][issue] / [PR #2457][pr]. It turns the subset that the W05a
investigation ([#2105][w05-issue], [draft #2106][w05-pr], [findings][findings])
showed to be ready into code: exact orthographic hidden lines for placed bodies whose
faces are planes, cylinders, cones, spheres or tori.
The code is `crates/geom/src/hlr` (layer L0). It reads the neutral `geom::brep`
records, uses no kernel or UI types, returns errors instead of panicking and builds for
`wasm32-unknown-unknown`. It does not register commands, create views, extract bodies
from the kernel or draw sections. The read-only kernel adapter follows the W02
extraction contract; until then callers build `Brep` records themselves.
## Using it
```text
prepare(&Brep, &Options) -> Result<Prepared, HlrError> once per body
hidden_lines(&[Prepared], &[Placement], &View, &Options, &cancel) -> Result<Drawing, HlrError>
```
- `prepare` checks the body (`Brep::validate`), recognises each face's surface, builds
its trim and classifies each edge as sharp, tangent or seam. `Prepared::report` says
what it found: surfaces and curves by kind, tangent and seam edges, faces whose trim is
unsupported, freeform faces and the largest boundary chord error.
- A `Placement` puts a prepared body in the scene: `world = rotation * local +
translation`, the rotation given by its columns. Several placements may share one
prepared body. A placement that is not a proper rotation (a mirror, a scale) is refused
with `HlrError::Invalid`.
- `View::new(direction_of_sight, up)` is an orthographic view. Screen coordinates are
`(p . x, p . y)` in millimetres, with `x = direction x up` normalised and `y = x x
direction`.
- `cancel` is polled during the work; when it returns true the call ends with
`HlrError::Cancelled`. `prepare_cancellable(&Brep, &Options, &cancel)` is `prepare` with
such a callback, polled between edges, faces and surface comparisons.
The `Drawing` holds one `Line` per drawable edge and per silhouette curve of each
placement, in order of placement. A line names its `placement` and `source` (`Edge { edge, tangent }`
or `Silhouette { face }`) and is split into `pieces`. Each `Piece` has:
| Field | Meaning |
| --- | --- |
| `t` | Parameter interval on the source curve. |
| `visibility` | `Visible`, `Hidden` or `Uncertain`. |
| `covered` | Another piece already draws the same projected curve here; draw only uncovered pieces. |
| `geometry` | The piece in screen coordinates as `Seg2` segments. |
| `accuracy` | `CurveAccuracy` of `geometry` (the [W01a vocabulary](drawing-geometry.md)). |
`Drawing::unsupported` lists the faces whose silhouettes were not drawn and why;
`Drawing::stats` counts curves, crossings, pieces, rays and uncertain pieces, with phase
timings where the platform has a clock (zero on `wasm32-unknown-unknown`).
## What is exact
| Source | Geometry | Accuracy |
| --- | --- | --- |
| Line edges; cylinder and cone silhouette rulings | one `Seg2::Line` | `Analytic` |
| Circle and ellipse edges; sphere silhouettes; torus silhouettes seen along the axis | `Seg2::Conic` arcs of at most 90 degrees | `Analytic` |
| Spline and intersection edges | chords between exact points, halved until each piece's control hull is within the sag target | `Sampled` (a bound from that hull) |
| Torus silhouettes in other views | chords between traced points, halved to 1e-7 of the torus's size | `Sampled` (measured deviation) |
An orthographic projection is affine, so a circle projects to an ellipse, which a
rational quadratic arc represents exactly. Chords are refined until their sag on screen
is at most 1e-7 of the scene size. A fitted edge record also carries its own bound, and
the visibility work allows for it.
Surfaces are recognised whether they are stored as analytic records, as revolutions of
lines and circles or as spline patches (to 1e-9 relative; a patch whose control points lie
in one plane is that plane). A spline curve or patch is taken for a circle, quadric or torus
only when it lies on it at enough points of every knot span to make the check exact for its
degree, with its largest distance there, scaled by how far apart its weights are, within
the tolerance: weights near zero cannot hide a swerve, and widely spread weights mean chords.
A recognised patch is trimmed and intersected in its own analytic chart. Faces that lie on one surface (a sphere
stored as two hemispheres, a cylinder split in two) share their silhouettes: the edges
between them are seams and are not drawn, and a silhouette running along such a seam is
one curve. A face joins such a set only when the first face's surface passes through its
own points to rounding (1e-13 of its size); recognition alone groups surfaces more loosely,
and such faces keep silhouettes of their own.
## Visibility
Projected curves are split where they cross or touch, found on a screen grid and
refined on the exact curves. Each piece is classified by rays toward the viewer against
every trimmed face: closed forms for planes and quadrics, a sampled quartic for tori.
A ray that grazes a face, meets one within its boundary tolerance or meets another body
right at its start makes the piece `Uncertain`; nothing is guessed.
Coincident curves (an edge and a silhouette on top of each other, two touching bodies)
follow the W05a policy: visible wins and duplicates are marked `covered`. The tolerance
comes from the curves' own accuracy (chord sag and fit bounds) plus a rounding allowance
of 1e-7 mm and 1e-9 of the scene size, never from the scene size alone, so small parts in
large scenes keep their detail.
`prepare` moves each body to its own centre, and the work is done in a frame centred on
the scene and shifted back at the end, so coordinates stay small: a part 1e7 mm from the
origin, whether placed there or modelled there, is classified as at the origin.
## Freeform faces
Faces that are not planes, cylinders, cones, spheres or tori are freeform. By default
they are listed in `Drawing::unsupported` with the reason `freeform face`, their
silhouettes are not drawn, and a piece whose ray meets one is `Uncertain`, never
`Hidden`. Without its silhouettes nothing marks where such a face's outline crosses a curve,
so curves are cut at the face's screen box and every piece inside the box that can reach
behind its nearest point (bounded by the curve's control points, not by samples) is
`Uncertain` too, the face's own edges included (faces left out for an unsupported trim
likewise). With `Options::freeform` set their silhouettes are contoured and their ray
tests use seeded Newton iterations: slower, `Sampled`, and subject to the misses listed
in the W05a findings.
## Limits
- `Options::max_tracks` (curves) and `Options::max_rays` bound the work, as do internal
caps: crossing grid entries (50 million), crossing candidate pairs (500 million, counted
before the search, so no set of pairs met grows with them), crossings (50 million) and
the surface comparisons that group a body's faces (50 million). Exceeding one returns
`HlrError::Budget`.
- `Options::trim_samples` is 1 to `MAX_TRIM_SAMPLES` (4096), and every coordinate of a
placed body at most `MAX_COORDINATE` (1e12 mm, the bound `geom::section` and
`geom::brep` put on stored numbers); otherwise the call returns `HlrError::Invalid`.
- `Options::tangent_edges` (default on) draws edges between tangent faces, flagged
`tangent: true`; drafting policy for them is W17.
- Tolerances are relative to the faces and the scene. They are meant for faces up to
about 10 m across and features down to about 1e-3 mm. A spline patch only a sliver wide
next to its radii (a few millionths of them) cannot be told from its neighbours' surfaces
reliably and is reported as freeform. Distinct surfaces that
recognition takes for one (closer than 1e-9 of their size or position, a nearly flat
cone's far apex included) share the own-face margin of 1e-6 of the face's size when a
ray leaves one of them.
- Not in this slice: parallel per-curve work and the shared work limits of
`geom::section`, the kernel adapter (after W02), perspective, section regions (W03),
views and commands (W07), and mirrored placements. Of the records the kernel now saves,
planes through three points (`PlaneThrough`) are drawn as planes; a body with a procedural
`SurfaceIntersection` curve or a `PlacedRevolution` surface (whose placement may scale it
unevenly) is refused by `prepare` with `HlrError::Unsupported` until the adapter handles
them.
## Fixtures
`crates/geom/src/hlr_tests.rs` builds bodies from records and checks analytic positions
and intervals:
- a cube in an isometric view hides its three back edges; a box in front hides the
middle of an edge over exactly the covered interval;
- a cylinder's silhouette rulings are exact lines, including one along the seam; a cone
frustum seen from the front has exact rulings and draws each edge-on circle once;
- a sphere's outline is exact conic arcs at its radius in three views;
- a disc bounded by a rational circle with unevenly spaced knots, with a knot span narrower
than rounding, or with a parameterisation that races through its first turn is drawn as
arcs turning the full 360 degrees; splines off their circle between evenly spaced samples
or inside such a narrow span are drawn as chords; exact arcs that turn almost all of
their 358 degrees in the first hundredth of their parameter, or several radians in a
knot span of 1e-6 or 1e-11, keep the turn of every piece;
- a torus stored as two halves, seen face on, is drawn at both equators, one silhouette
each; an edge under it is hidden over exactly the intervals the tube covers; seen
obliquely its outline spans R + r on both sides with a deviation under 1e-3 mm;
- cone frusta and narrow cone sectors a hair off one axis or with axes 3e-7 rad apart, and
spherical caps through one circle with centres 5e-7 mm apart, keep their own outlines;
- a cylinder 1e5 and 1e7 mm from the origin draws as at the origin; a cone trimmed to its
tip 1 mm below the apex hides nothing above it;
- a freeform patch is reported and makes the edges it covers uncertain, or hidden when
freeform faces are enabled; a flat spline patch hides them for certain; a long edge behind
a freeform tube is uncertain where the tube may cover it and visible elsewhere, and so is
a sloping edge that only dips behind a freeform patch near its end, while one that leaves
the patch's box below its level but crosses the box above it stays visible;
- a curve that swerves 2.9 mm off its circle where its weight is 1e-12, and a nearly straight
cubic 0.002 mm off a circle of radius 1e7, are drawn as chords; an edge whose depth dips
behind a freeform patch between its samples is uncertain there, and so is an arc seen
along its plane whose projection folds back under a freeform patch between two samples,
and the edge of a freeform sheet that curls back over it 0.005 mm above;
- a torus patch stored as a rational spline is recognised and draws its outer equator as an
exact arc, and one with a bump in a knot span 0.002 of its parameters wide is not taken for
a torus; a big circle passing under a thin freeform tube in a coarse scene is uncertain
there;
- two strips of a torus 0.0003 rad wide keep their 0.006 mm of outline;
- two touching boxes draw each shared edge once;
- a box whose top plane record stands one rounding (or 1e-11 of 100 mm) off its edges still
hides its back edges, and so does a 0.001 mm cube 1e7 mm out whose top plane is one step
above its edges; a plate's sixteen holes each hide the same part of their bottom circle;
eight coaxial cylinders seen along their axis show one circle; 300 boxes apart on screen
are each drawn as the first one; an arc that bulges across a line 1 mm off its chord still
crosses it twice; the trim's chord grid agrees with a scan of every chord; surface groups
agree with comparing every pair of faces, and preparing can be cancelled;
- mirrored placements, bad body indices and bad views are refused; cancellation and
budgets return errors;
- a box stored with planes through three points draws the same bits as one with
axis-stored planes; placed revolutions and procedural intersection curves are refused as
not supported yet;
- torus ray roots for thin, large and nearly tangent cases, and torus silhouettes near
the axis direction.
[rfc]: https://github.com/tangency-labs/solidcraft/issues/860
[plan]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6234779815
[findings]: https://github.com/tangency-labs/solidcraft/issues/860#issuecomment-6243691483
[issue]: https://github.com/tangency-labs/solidcraft/issues/2455
[pr]: https://github.com/tangency-labs/solidcraft/pull/2457
[w05-issue]: https://github.com/tangency-labs/solidcraft/issues/2105
[w05-pr]: https://github.com/tangency-labs/solidcraft/pull/2106
+585
View File
@@ -0,0 +1,585 @@
# Drawing-owned sheet sketches: records, solving and command lifecycle
These increments of [W38a #7082][issue] build on the embedded-drawing K3b/K4-prime
receiver in [#3891][storage], not the superseded separate-file session. R1 adds
records, R2 read-only solving and atomic edits, R3 composes the shared store,
and R4 connects a bounded command lifecycle. This is not the complete native or
browser Drawing UI, all-tools parity, or completed W38a.
## Stored meaning
`Drawing.sketches` contains `Arc<SheetSketch>` records. A record has `id`, `sheet`,
`name` and the existing `solidcraft_sketch::Sketch` payload. Its id belongs to the
same drawing-wide namespace as the drawing, source and sheet ids. Its `sheet`
reference must resolve inside that drawing. Local ids such as `origin`, `p1`
and `l1` remain local to the payload and may repeat in a different sketch.
`Drawing::sheet_sketch(id)` searches only that drawing; it does not inspect the
active model or search other drawings.
Positions and length dimensions are paper XY millimetres, angles are radians.
The origin is the physical sheet's lower-left corner, x right and y up, exactly
as in the paper scene. It is not the drawing-border corner; no margin translation
or view scale is applied.
Changing `Drawing.units` is not a geometry conversion. For example the authored
rectangle (15,10) to (55,30) remains 40 by 20 paper mm when units are `in`.
There is no view scale or source path in this sheet-owned record.
Point expression strings and dimension parameter names are retained verbatim.
Record loading neither evaluates them nor creates a model parameter. A successful
read does not establish that a name resolves in the design, that expressions
have compatible units, or that constraints solve. The explicit R2 solve/edit
interface below performs those checks on a candidate before adopting it. No
model evaluation or geometry repair occurs during record loading.
## Current supported payload
The first slice admits the shared 2D point/curve/constraint payload: finite
bounded points, valid local identities and point/curve references, bounded
splines, radii and conic weights, and constraints checked by the existing
`Sketch::validate`. Periodic splines use the existing validator. The fixed
local origin must remain point zero. Input is not snapped or solved.
**Explicitly not connected yet:** nonempty links (including generated text),
patterns, offsets/freeform offsets and 3D wires, and the model-only slice/3D
palette state. They are refused, not dropped. Linked points/curves are also
refused. Enabling these auxiliary record families requires their bounded
validation and Drawing receiver paths; it is later W38a work. Record admission
alone does not enable a tool. R4's explicit rectangle/line/point command subset
is described below; it supports source expressions, not a separate numeric-only
sketcher, and does not enable these auxiliary payloads.
## Identity, ordering and compatibility
An absent/empty `sketches` list is omitted on write, retaining the previous
record shape. One `drawing/format/pending/sheet-sketches.rs` entry versions the
optional addition; there is no new design-format step or manually edited
version number. Builds that do not know the pending change refuse the file.
Records are serialized by numeric drawing object id, independently of their
insertion order, without mutating the live vector. Reading adopts that order.
Sheet order and all local sketch geometry/ids remain unchanged. Duplicate or
zero drawing ids, duplicate local ids and unknown owners are errors. There is
no nearest-sheet fallback. New ids allocated by `Drawing::allocate_id` also
avoid existing sketch records.
## Bounded admission
The encoded drawing still uses the existing 16 MiB limit. `from_value`, used
by embedded plain-JSON designs, applies the same new raw record checks before
typed sketch decoding. The new admission limits are explicit implementation
bounds, not a claim about geometric precision:
- At most 1,000 sheet-sketch records per drawing.
- Each payload uses the shared `MAX_POINTS`, `MAX_CURVES`, `MAX_CONSTRAINTS`
and `MAX_SPLINE_POINTS` caps. Their point/curve/constraint and spline-index
entries together cannot exceed 80,000 across the drawing.
- Stored string values are at most 4,096 bytes; local ids/field names at most
512 bytes, and sketch names follow the drawing's 256-byte name rule. Counter
maps have at most 64 prefixes. Expressions cannot be blank or contain controls.
- Raw payload depth is at most 32 and all sketch records together at most
1,280,000 JSON nodes and 16 MiB of string/key bytes. These do not replace the
encoded-file cap or make a pre-existing JSON Value allocation bounded.
- Constraint checks have a cumulative 2,000,000 work allowance. Slot-length
validation is charged conservatively for its nested constraint searches.
The typed edit boundary repeats array, identity, range, owner and constraint
checks. Nonfinite positions/dimensions are errors before a drawing with sketches
can be written by `Drawing::to_json`; NaN is not silently stored as JSON null.
The shared Sketch type and model-sketch reader are unchanged. No new solver,
cache, active-sketch store, external font, dependency or CI setting is added.
## Read-only solving and staged edits (R2, revised after review)
`Document::solve_sheet_sketch(drawing_id, sketch_id)` still resolves the exact
stored sketch against the current containing design, without publishing its
solution. This public document read validates the complete drawing. Repeated
calls read the current source values, not a model-evaluation cache.
`Drawing::solve_sheet_sketch(design, sketch_id)` is the staging counterpart:
owner/header identities, cumulative sketch budgets and the selected payload are
checked, without deeply validating every unrelated payload. It is used only
inside a drawing editing boundary, or after the full document-read check.
`Drawing::edit_sheet_sketch(design, sketch_id, change)` stages an edit on this
Drawing. The callback receives a private `&mut Sketch`. The original payload is
checked before the callback, the candidate before solving and its solved result
afterwards. Cumulative entity and validation-work limits include the unchanged
sketches as well, before the solver runs. Errors leave the Drawing unchanged;
a no-op retains its selected record Arc. The operation reads source parameters
and uses `Document::solve_sketch`; it cannot write into the source Document.
The previous `Document::edit_sheet_sketch` publication API has been removed.
The engine captures the immutable source and calls the staged operation inside
`Session::edit_drawing`. That existing Session boundary validates the **whole**
result once and adopts only the open Drawing. Its command's history therefore
belongs to that same Drawing. The shared store never publishes a Document copy
itself for sheet geometry. A library caller adopting a staged Drawing likewise
must validate the complete record at its publication boundary.
Unchanged Arcs are not a validation certificate: the complete check still rejects
an invalid unrelated sketch, even if it was not edited and its Arc is unchanged.
No implicit trusted-record cache has been added. Full reading and saving retain
complete validation; `Drawing::to_json` now applies that rule regardless of
whether the drawing contains sketches. An invalid empty-sketch drawing is no
longer serialised as a successful file.
The current embedded G3 Source means the containing design. `Sheet.documents`
is only the title block's component selector. Source values keep their units;
bare point-expression values keep paper-mm/degree defaults. For example,
`Inset = 1 in` plus bare `5` produces 30.4 paper mm. Names, expressions and their
error states remain authored data, not baked values. Only named references need
a declared Source; unrelated source errors do not retarget the sketch.
This is not a drawing-local parameter-definition syntax, another solver, an
incremental validation certificate or a whole-process latency/memory guarantee.
The original unsupported auxiliary payloads remain refused. Resource limits and
error expectations are unchanged; timing evidence and executed checks are in the
PR rather than being asserted as performance thresholds in correctness tests.
## Acceptance and remaining work
The record tests live in the existing `crates/doc/src/drawing_tests.rs`. Original
fixtures cover a paper rectangle and stored expressions; deterministic ordering;
drawing/local identity separation; unknown owners; absent/empty sketches; size
limits before typed allocation; finite/index/constraint rejection; unsupported
auxiliary data; inline-design round trips without model changes; and bounded
JSON mutations. The existing `crates/io/src/design/drawing_tests.rs` also exercises
these records through real design-container and plain-JSON writers/readers:
round trips retain the paper-mm geometry, expressions and drawing-local ownership;
changing one sketch changes only its drawing entry, not the model/container metadata
or a second drawing; damaged sketch records refuse the whole input without falling
back to another drawing's sheet. These checks do not run an editor or evaluate a model.
The additional R2 tests in `drawing_tests.rs` check source resolution without
mutation, paper-mm defaults under inch display units, fresh source values, atomic
success/refusal, conflict and malformed-expression handling, exact ownership,
source cycles, unrelated errors and no-op Arc identity. They exercise the actual
shared evaluator and solver. Actual executed results belong in the PR, not this
design note.
R3/R4 compose the actual W38.0 #6336 store with the embedded K session and route
a first set of sheet commands through R2, including entry, finish/cancel and
history. Drawing-owned dimension definitions, the remaining tools, cached live
presentation and native/browser UI parity still need implementation and acceptance.
The record-only container checks above do not qualify all those editing paths. W38d's later
saved-fill integration must retain outer-plus-holes identity after edits. W38b/c/e
and source-edge projection remain with their existing owners.
[issue]: https://github.com/tangency-labs/solidcraft/issues/7082
[storage]: https://github.com/tangency-labs/solidcraft/pull/3891
## Shared command-store receiver (R3)
The existing W38.0 store from CamWight's #6336, revision `07984653`, is now
composed with this embedded K receiver. This is a source-compatible port of the
same `cmd::sketch_store`, not another store or a replacement main-target PR.
R3 retained the original module, including its routing scan and null/exact-number
tests; R4 extends that same module with the explicitly owned sheet adapter. The calls present on this receiver go through `target`, `active`,
`read`, `write`, `update` and `checked` as in the original work.
The receiver predates several unrelated main changes. This adaptation does not
import pattern-expression refresh, live-link caching, Cancel Sketch, newer
projection de-duplication, coincident-circle tools, motion traces, or later
MidPoint/standard-part behavior just to make a cherry-pick apply. In particular,
`run_links` retains this receiver's original projection behavior inside the
shared update transaction. Model-only joint/plane lookups stay explicitly
marked. Original author/source references remain in the commit and PR.
At the R3 checkpoint this was **model-store routing**, not the sheet receiver.
R4 below changes the part gate only for its admitted tools; no drawing id is put into
`Session::active_sketch`. Two receiver-specific engine tests exercise model/sheet
id collisions, refusal of model sketch commands with a drawing open, successful
model editing after closing it, and callback failure without publishing a clone.
Their executed results, if available, belong in the PR.
The R4 command slice below now supplies explicit sheet ownership and R2 routing.
Drawing-owned dimensions, remaining tools, live presentation and UI acceptance
remain incomplete. Integrating this port is not evidence that #6336 landed on
main or that its independent full gate qualifies the combined receiver.
## R4: exact-owner command lifecycle
`drawing.sketch.create {owner, name?}` creates and enters a sketch on the exact
encoded sheet id of the open drawing. `drawing.sketch.edit {sketch}` enters an
existing exact encoded id; repeating it on the same sketch preserves the first
cancel boundary. `drawing.sketch.inspect {sketch?}` reports the resolved paper-mm
points, local entity ids and solver state without adopting that solution.
The existing `sketch.rectangle.two_point`, `sketch.line` and `sketch.point` tools
use the same geometry implementations through the shared store's sheet adapter.
There is no parallel sketcher or alternate model-feature lookup. A separate
session entry names drawing, sheet and sketch explicitly; the model's
`active_sketch` is never assigned a sheet id. An explicit tool target can confirm
that entry, not silently switch to another drawing or a same-numbered feature.
The ordinary model paths of these tools remain available with no drawing open.
Point and line expressions read the containing design's parameter values, under
R2's declared-source checks. Bare expression values are paper millimetres even
when the model displays inches. The legacy tool callback may read a copy of the
source document, but any attempted mutation of that source is refused before
publication. R2 validates and solves the changed sketch before storing it.
`sketch.finish` ends sheet editing. `sketch.cancel` restores only that sketch's
entry record, or removes a sketch created in that entry, then leaves editing.
The cancellation is itself one drawing undo step when data changed; Undo restores
the edited record and its active entry, and Redo returns to the cancelled state.
Other sketches, drawing properties, other drawings and model history are not
rolled back. Session checkpoints, scratch previews and restored drawing history
carry the explicit sheet entry. Switching or deleting drawings while it is active
requires Finish or Cancel. These commands must not evaluate a stale model merely
because a model-only operation was attempted from the drawing.
This first command slice deliberately does **not** enable dimensions that define
parameters, the other sketch geometry/modify/constraint tools, text, patterns,
offsets, clipboard, live drag or model projection paths in Drawing. They remain
explicitly unavailable, not silently redirected to model geometry. New source
bindings, auxiliary payload validation, all-tools parity, cached solved-state
presentation and actual native/browser editor UI acceptance remain subsequent
work. W38d saved-fill association is not enabled by these commands.
The lifecycle acceptance cases are in the existing engine `cmd/drawing_tests.rs`:
paper rectangle size, source-read-only point/line expressions, failed calls,
finish/cancel and history, checkpoint/scratch/reopen, colliding identifiers across
two drawings and a model sketch, schemas/hostile input, and no stale-model
re-evaluation. Executed results and any remaining failures belong in the PR.
New command messages are supplied as a pending locale delta, per current project
rules. This pinned K receiver predates the pending-catalog reader; native UI
translation qualification requires the already-merged shared prerequisite #6448
(`1ceab2ed4c03832f1937e0da98b9c8c50f49aa3e`) to reach `drawings`. The four pending
ids were checked against the current main English catalog and are not duplicates.
This feature does not modify the i18n loader, shared catalogs or CI to bypass that
integration requirement.
## Sheet ownership and deletion
The shared sheet-deletion rule belongs to the sheet commands (#3875): refuse a
sheet that contains objects and list its views/sketches; only an explicit
"delete everything" choice removes that sheet and its contents as one drawing
undo step. W38a does not add a competing deletion command or silently detach a
sketch. A missing owning sheet remains invalid. The eventual sheet command must
also respect the active edit boundary (finish or cancel before switching or
removing its owner). This records the agreed integration rule, not acceptance
of a sheet-deletion UI that is absent from this receiver.
For future shared-scene integration, sheet sketches use the same lower-left
frame and the proposed paint band after views and before tables/title blocks.
The paper ObjectKind/scene implementation remains with the shared-scene owner;
this review change adds neither a second scene nor an exporter.
## R6: more shared geometry tools and sheet-safe snap input
The sheet command receiver additionally admits the existing centre/two-point/
three-point circles, centre-point/three-point arcs, and centre/three-point
rectangles. Only their command part is extended; their model constructors,
constraint rules and solver implementation are reused unchanged. The same R5
Session publication boundary validates the drawing and owns its undo step.
This is command coverage, not interactive GUI or all-tools acceptance.
Numeric positions and radii remain paper millimetres. Centre and arc point
arguments retain the existing exact local-point-id and source-expression forms;
numeric-only parameters keep the existing command contracts. This increment
does not add radius expressions, drawing dimension definitions or source-edge
projection. Ordinary modelling commands retain their existing behaviour.
A design may retain an active model sketch while a drawing is open. Shared point
preprocessing must not turn a model `mid:`, `on:`, `vertex:` or `on_edge` snap into
a newly allocated model-local point id and then interpret that id on the sheet.
The sheet route now has no model target and explicitly refuses these automatic
snap/projection forms before it reads or changes a model sketch. This includes
malformed marker objects that also contain an `at` fallback. Plain 2D points,
expressions and exact existing local point ids remain available. The model snap
path is unchanged. Dedicated sheet/view projection remains later work.
Original synthetic tests use a deliberate model/sheet local-id collision,
independently calculated circle centres/radii and signed arc endpoints,
rectangular corners and construction diagonals, undo/cancel/reopen, invalid
geometry and exact targeting. A separate acceptance test calls
`Session::accept_computed` from a scratch session that opens a different drawing;
it checks the adopted owner and entry, cancellation and undo, closing, the
original checkpoint and host file state. Results are recorded against the exact
source in the PR, not inferred from compiling these tests.
The pending locale file uses the existing underscore-normalised command keys
(`cmd.drawing_sketch_create`, `cmd.drawing_sketch_edit`,
`cmd.drawing_sketch_inspect`). It no longer duplicates main's
`cmd.sketch_cancel`. The actual #6448 loader/catalog sync is still required by
this older receiver. Likewise, when main's #3938 reaches it, retain **one**
`sketch.cancel` specification/label/icon and dispatch to the sheet cancel path
when a drawing is open, keeping main's complete model-cancel branch. This
increment neither copies that model-entry implementation partially nor claims
that the outstanding main-to-drawings composition has happened.
## R7: bounded polygon and slot tool admission
The existing inscribed, circumscribed and edge-defined polygon commands, together
with centre-to-centre and overall-length slot commands, use the same drawing-owned
sketch receiver and drawing-local undo as R4/R6. There is no new polygon, tangent,
arc, construction-circle or constraint algorithm. The built-in polygon side cap
(3–200) and the commands' positive-length/width checks remain unchanged. Invalid
or cross-owner requests publish neither the sketch nor a model mutation.
The tools use their existing numeric point and radius/width contracts; values are
physical paper millimetres, not model display units or a view's scale. An
inscribed/circumscribed polygon retains its construction support circle and local
constraints. Slots retain their tangent semicircular caps. Neither tool creates
a model parameter. The retained R5 validation and Session publication paths
remain the only commit boundary.
The existing engine Drawing test module checks original analytic square vertices,
centres and radii, exact cap radii and positions, construction status, owner and
model isolation, undo/redo, a polygon save/reopen cycle, and atomic refusals of
invalid geometry and a model sketch as target. These are tests, not a claim that
the native or browser editor is connected. Text/geometry sheet-scene tools and
view-aware sketching still require the owners' shared-scene and UI integration.
### R8: six shared geometric constraints
The same paper receiver now admits the existing horizontal/vertical, equal,
parallel, perpendicular, concentric and midpoint constraint commands.
They use the model sketcher's constraint builder and solver on an exact
drawing/sheet/sketch target, but publish the solved geometry only through
`Session::edit_drawing`, with drawing-local undo/redo. A constraint cannot
select another sheet or a model sketch. These are geometric constraints on
authored sheet sketches, **not** sheet annotation dimensions or model-linked
projected geometry. The original model source and its parameters are read-only.
Six remaining expression-parser sites for circle/arc point arguments have
independent inch-design acceptance cases: circle through-point, all three
three-point arc inputs, and centre-point arc centre/end. Source expressions
remain authored while bare literals use paper millimetres. Until sheet-local
snap resolution is implemented, `mid:`, `on:` and `x:` input markers and
model `vertex`/`on_edge` projections refuse rather than implicitly reading
the model's active sketch. Main's `x:` spelling is included proactively in
this refusal, **not implemented** for sheet curves.
Coincident, symmetry, dimensions and advanced constraints remain
model-only pending dedicated qualification. Canonical Cancel and the shared
receiver/locale merge are still prerequisites for a GUI-ready editor.
### R9: tangent and collinear constraints on paper
The existing `sketch.constraint.tangent` and
`sketch.constraint.collinear` commands now also use the same sheet-local
sketch receiver. No constraint formulation, solver, model handler or saved
schema changes. Tangency remains the existing line/circle/arc geometry
constraint; collinearity uses the existing straight-line relation. Both
retain explicit sheet ownership and single-session publication, and refuse
cross-owner targets without recording a command or changing model state.
The expanded shared-constraint engine acceptance checks analytic
line-to-circle tangency, two lines sharing one infinite supporting line,
drawing-only undo/redo and file reopening, and atomic refusal of a model
target. This does not qualify other constraint commands, interactive
snap/picking, drawing-owned dimensions, the native/browser editor or the
full workspace CI suite.
### R10: fixing sheet geometry and point/axis symmetry
The existing sketch.constraint.fix and sketch.constraint.symmetry commands
now use the same exact sheet target, shared sketch implementation and solver as
the admitted constraints above. Fix/unfix changes only the selected sheet
sketch's point constraints or curve fixed state. Symmetry uses the sketcher's
existing point/axis and curve mirroring logic; it does not construct a second
geometry engine or a model feature.
Both commands publish only through Session::edit_drawing, with one drawing
undo step per changed command. Incorrect sketch ids, unknown local entities or
an absent editing boundary fail without changing the source design or drawing
history. Analytic point/line symmetry and fix/unfix, undo/redo, isolation and
JSON save/reopen cases live in the existing engine Drawing tests.
This is not shared annotation dimension support, pointer-driven UI integration,
canonical sketch.cancel composition or full native/browser acceptance.
Those remain separate dependencies and deliverables.
### W38a R11: sheet-local construction and deletion
The existing `sketch.construction` and `sketch.delete` commands now accept the active,
exactly owned sheet sketch through the common sketch command receiver. Construction state
and entity deletion publish only through `Session::edit_drawing`, with one per-drawing
undo/redo step; the model and its parameters are read-only. In particular, deleting a
sheet entity cannot invoke model-parameter pruning. Invalid local entity ids, a foreign
sketch selector and malformed edits fail without a partial publication.
These commands extend the **command-driven** subset, not an independently accepted
native/browser interactive editor. Dimension constraints and annotations attached to
drawing views are separate responsibilities; the latter belong to W19 (#4896).
### W38a R12: reference-only dimensions on paper
The existing `sketch.dimension` command now accepts **reference (driven) dimensions**
on the active, exactly owned sheet sketch. Call it with `driven: true` and a
sheet-local line, circle, arc or point pair in `entities`. It uses the same
sketch constraint builder and solver, reports the stored solved measurement in
physical paper millimetres or degrees, and publishes through the one
`Session::edit_drawing` boundary. The existing `sketch.dimension_text`
command places or resets the reference dimension label in paper coordinates,
using the same drawing-local undo/redo history.
The containing model is **read-only**: reference dimensions have no model
parameter binding, never create named or generated source parameters, and
cannot evaluate or edit model features. A sheet request that supplies a driving
dimension, a dimension `value`, a parameter definition, or `scale_sketch: true`
is refused explicitly instead of passing through the model-only parameter path.
An incorrect drawing/sketch owner or unknown local entity is refused atomically.
Original engine cases cover the 40 mm paper-length measurement when the source
design displays inches, label placement, drawing-only undo/redo and JSON reopen,
source-model identity, and wrong-owner/unsupported-value refusals. A reference
constraint is not a driving sheet-sketch dimension definition, and it is not a
drawing-view annotation (W19/#4896). No new solver, schema, locale, scene,
desktop/browser interactive tool, or full-CI qualification is claimed here.
### W38a R13: shared Coincident constraint on a sheet
The existing `sketch.constraint.coincident` command now admits an active sheet
sketch, without adding another sketcher or constraint algorithm. Its initial
duplicate/endpoint preflight reads a solved candidate through
`sketch_store::read_for_command`, resolving the exact drawing, sheet and sketch
instead of looking up a model feature by the same local id. The same helper
continues to resolve and read model sketches by their existing rules when no
drawing is open.
Point-to-point coincidence, a point on a curve (in either pick order) and the
closest endpoints of two distinct straight lines retain the existing command
semantics. A coincident point pair already constrained together returns its
existing constraint id without a document write or undo step. A new relation
continues through the shared constraint builder and solver, with one
`Session::edit_drawing` publication, one drawing-local history step and no
source-model mutation. There is no implicit switch to a second sheet.
Original engine cases check local point coincidence and idempotence, point-on-
curve selection, two-line endpoint selection, undo/redo, JSON reopening,
model/parameter isolation and atomic refusal of foreign sketch ids or missing
local entities. Their executed results and the exact tested commit belong in
the PR. This does **not** complete sheet sketch driving dimensions, model
projection, sheet editor UI, all-tool parity, canonical model/sheet Cancel
composition or the full repository CI gate.
### W38a R14: read-only chain selection and solve reports
The existing `sketch.chain` and `sketch.solve` commands now accept the active
sheet-sketch receiver, without adding command ids or changing model-sketch
behavior. Both use the same sketch-part gate as the geometry tools: with a
drawing open, an exact active drawing/sheet/sketch entry is required. Explicit
`sketch` inputs may only confirm that entry; an absent, invalid or foreign
owner is never resolved as a model feature.
`sketch.chain` reads a solved paper sketch through the shared command store and
uses the original connected-curve selection algorithm. `sketch.solve` reads the
same drawing-owned record through its existing read-only solve boundary and
reports status, degrees of freedom, failed constraints and a bounded profile
count from the shared drawn-profile finder. Neither query publishes the solved
geometry, changes a source parameter, triggers model evaluation or creates a
drawing undo step. Invalid local curves and wrong owners fail without mutation.
Existing engine regressions and two new sheet-specific cases distinguish the
model receiver from paper-owned queries, including profile counts, no-op
history, source-model state and cross-owner refusals. This only enables
**command-level inspection**. The native/browser pointer-driven Drawing
workspace, canvas selection, ownership-aware event routing and visual
acceptance remain with the agreed UI integration work; these two commands
alone do not make a sheet sketch editable through the GUI.
### W38a R15: point repositioning and sheet palette settings
The existing sketch.move_point command now uses the same point-drag geometry on
the exact active drawing/sheet/sketch, with destination coordinates in paper
millimetres (not the displayed source-design units). Its solved candidate is
published through the shared sheet receiver as a single drawing-local undo step.
It is not the live pointer gesture or the desktop/browser canvas hookup.
sketch.options now supports read-only inspection and changes to the existing
2D sketch visibility, grid and snap toggles on the same sheet receiver. A read
never creates history; changes use Session::edit_drawing, retain the source
model unchanged, and persist with the drawing. The model-only slice and
sketch_3d modes are refused when enabling them on a paper sketch. The model
version of both existing commands retains its original route. Native/browser
palette wiring and pointer-frame interactions remain with the shared UI owner.
### W38a R16: sheet-sketch driving dimensions and authored expressions
The existing `sketch.dimension` command now admits a **driving sheet-sketch
constraint** in addition to its earlier value-less `driven: true` reference
measurement. The shared constraint builder and solver are unchanged. A driving
value is authored on that *sheet*, in paper millimetres (or degrees for angle
input, stored in radians), regardless of the containing design's display units.
For example, `value: 50` means 50 paper mm in an inch-based design.
The dimension's authored expression lives in the existing sketch constraint's
`param` slot; it is *not* a newly created source-model parameter.
An expression can read declared design-source parameters without modifying
them: `Inset + 5` with `Inset = 1 in` resolves to 30.4 paper mm and is kept
verbatim through save/reopen for read-only re-resolution. The single Drawing
source and exact sketch owner are required for any external references.
The receiver extends its parameter-value map with resolved expression values
under those authored keys before invoking the existing solver. Source parameter
keys are never overwritten. An identical expression key cannot be used to
drive both a length and an angle in one sketch, since that would be ambiguous.
Invalid expressions, unknown names, unit mismatches, solver conflicts and
foreign owners fail without publication.
Successful commands still publish **once** via `Session::edit_drawing` and
produce one drawing-local undo step. The source model parameters, features,
evaluated geometry and model history remain unchanged. Sheet dimensions refuse
`name = expression` definitions, `scale_sketch: true` and automatic
`if_redundant` reference fallback; those require a separate, agreed sheet
parameter and constraint workflow. The existing model sketch dimension path,
reference dimensions and W19 drawing-view annotation ownership are unchanged.
Original R16 engine acceptance checks cover paper-mm driving values with inch
display, stored source expressions with changed-source read-only resolution,
saving/reopening, drawing-local undo/redo, and atomic invalid/foreign
refusals. This is a command/store increment; it does **not** claim an
interactive desktop/browser sheet editor or full repository CI.
### W38a R17: shared 2D command coverage on a paper-owned sketch
The same Drawing receiver now admits the remaining **bounded, self-contained 2D
geometry commands** that can already be represented by the stored sheet
`Sketch` payload. These are not new geometry implementations or command ids:
- Creation: midpoint line, tangent arc, two- and three-tangent circles,
center-point and arc slots, ellipse, cubic fit/control splines, degree-five
control spline, conic, G1/G2 blend, and rounded rectangle.
- Editing: fillet, three chamfers, trim, path trim, extend, break,
scale, move/copy, mirror, centerline, infinite line and a completed
non-live `sketch.drag` command.
- Compound `sketch.draw.dimensioned` creates its supported geometry and paper
driving dimensions together in **one** drawing edit; it never uses model
parameters or the model-only automatic reference-dimension fallback.
All of these retain the original shared sketcher, solver and constraint builders.
Every edit uses the exact active drawing, sheet and sketch and is published
only through `Session::edit_drawing`; the original model remains read-only.
The model-only parameter-pruning calls after trim and break have been gated to
model sketches. Fillet and tangent-circle radius expressions belong to the
sheet constraint itself, are evaluated with the paper-mm default and can refer
to existing source parameters without creating model parameters.
The bounded sheet format currently accepts splines only through degree five.
Consequently G3/G4 blend requests are refused rather than stored in a format
that cannot validate. Text links, arbitrary projected/reference links,
pattern records, offset associations and 3D wires are still explicitly
unavailable: admitting them requires their own bounded ownership/validation,
resolution and persistence paths. They must not be silently discarded.
The non-live drag command represents **one completed gesture per call**.
Intermediate `live: true` sheet frames and `sketch.drag_end` are not enabled:
a reliable single-undo pointer gesture and the native/browser hit-test
provider must be integrated by the established UI owner. This code does not
add a parallel sheet canvas, navigation handler, command registration or
a second solver.
The existing model command implementations and parameter semantics remain
unchanged; `sketch.fillet` adds an `expression` output only in the sheet
case. Model-only APIs and replies do not gain sheet-specific fields.
W19 view-attached annotation dimensions, W38b view sketches, W38c
projection, W38d saved fills and W38e images retain their separate owners.
This slice does **not** certify complete W38a, native/browser editing,
canonical model+sheet Cancel composition, K/store imports or full CI.
+2 -2
View File
@@ -200,8 +200,8 @@ Errors: an unknown tool or arguments that don't match the tool's schema are JSON
result with `isError: true` and the engine's message, which the model can read and correct.
`batch` rollback goes back to an engine checkpoint (`edit.checkpoint`) taken before the first
step: the design, the active sketch and the undo history are restored exactly as they were,
however long the history is. Batches that call `file.new` or `doc.open` get the design back,
step: the design (its drawings too), the active sketch, the drawing open and the undo history
are restored exactly as they were, however long the history is. Batches that call `file.new` or `doc.open` get the design back,
not its file path. (An older app without checkpoints falls back to undoing as many steps as
the batch added to the undo history.)
+16 -7
View File
@@ -9,7 +9,9 @@ Where the expected values come from (the **Source** column):
No expected value comes from a commercial CAD product.
**83 / 83 cases pass**; sources: 62 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet.
**90 / 90 cases pass**; sources: 69 analytic, 21 freecad. 0 known failure(s) tracked in issues; 0 case(s) without expected values yet.
**Validation note:** Case 95's analytic centre-of-mass reference was corrected after the above successful run. Its previous pass status is historical and requires a new oracle replay against the corrected reference.
| Case | Source | Result | Surface distance mm (max / mean) | Method | Notes |
|---|---|---|---|---|---|
@@ -29,13 +31,13 @@ No expected value comes from a commercial CAD product.
| 14-combine-cut | analytic | pass | | box [-25,25]^2x[-20,0] minus sphere r=15 at the origin (lower hemisphere removed): V = abc - 2/3 pi r^3, hemisphere centroid z = -3r/8; A = 2(ab+bc+ca) - pi r^2 + 2 pi r^2 | |
| 15-combine-intersect | analytic | pass | | cube [-20,20]^3 intersect sphere R=25: sphere minus 6 disjoint caps of height h=5 (caps cannot overlap: two would need x^2+y^2 > 800 > R^2); V = 4/3 pi R^3 - 6 pi h^2 (3R-h)/3, A = 4 pi R^2 - 6 * 2 pi R h + 6 pi (R^2-20^2) | |
| 16-sweep | analytic | pass | | circle r=3 swept along line 30 (+Z) + tangent R20 quarter arc + line 30 (+X): two cylinders + quarter torus (Pappus: V = pi r^2 R theta, A = 2 pi r R theta; torus centroid at rho = (R^2 + r^2/4)/R * sin(theta/2)/(theta/2) from the bend axis along the bisector) + 2 end disks | |
| 17-loft | freecad 1.1.4 | pass | 0.013 / 0.0019 (worst at -7.9, -10.8, 31.2) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | |
| 17-loft | freecad 1.1.4 | pass | 0.013 / 0.0017 (worst at 8.7, -6.7, 37.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | |
| 18-hole-features | analytic | pass | | plate 60x40x15 minus: counterbore (r7 x 5 deep + r4 through), countersink (90 deg cone frustum r6->r3 over 3 mm + r3 through), blind r3 hole 10 deep to the shoulder with a 118 deg cone point (height 3/tan59, centroid 1/4 up from the apex); areas: removed top/bottom disks, cylinder walls 2 pi r h, cb floor annulus, cone laterals pi r s | |
| 19-l-bracket-constrained | analytic | pass | | solved L: (0,0)-(60,0)-(60,8)-(8,8)-(8,40)-(0,40); area 60x8 + 8x32, extruded 20: V = A2 h, A = 2 A2 + perimeter*h, centroid of the two rectangles | |
| 20-slot-constrained | analytic | pass | | obround: arc centres (0,0) and (40,0), R6, tangent horizontal lines y=+-6: A2 = 2rL + pi r^2, V = A2 h, A = 2 A2 + perimeter (2L + 2 pi r) h | |
| 21-draft | analytic | pass | | 40x40x30 box, 5 deg draft hinged on the bottom (z=0), pull -Z: square frustum 40 -> 40 + 2*30*tan5; V = h/3 (A0 + A1 + sqrt(A0A1)), sides 4 trapezoids of slant h/cos5 | |
| 22-split-body | analytic | pass | | cylinder r20 z -30..30 split by the plane through the X axis tilted 30 deg (z = y tan30, stays within /z/<=11.6 inside the cylinder): 2 congruent halves (point symmetry), V total = pi r^2 h, A total = cylinder + 2 ellipses pi r (r/cos30) | |
| 23-bracket-multifeature | freecad 1.1.4 | pass | 0.007 / 0.0010 (worst at 57.3, 49.3, 25.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part makeFillet, Part extrude join, Part extrude cut, Part makeChamfer | |
| 23-bracket-multifeature | freecad 1.1.4 | pass | 0.007 / 0.0010 (worst at 62.7, 48.7, 25.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part makeFillet, Part extrude join, Part extrude cut, Part makeChamfer | |
| 24-user-parameters | analytic | pass | | width 60 x depth 40 x height 15 block from the origin with a centred d10 through hole: V = (wd - pi r^2) h, A = 2(wd - pi r^2) + 2(w+d)h + 2 pi r h | |
| 25-sketch-triangle | analytic | pass | | solved triangle (0,0), (50,0), P = t(cos60, sin60) with t = 25 + sqrt(150) from /P-(50,0)/ = 45 (the root nearest the drawn apex); V = (1/2 * 50 * Py) * 5, A = 2 A2 + perimeter*5, centroid = vertex mean | |
| 26-sketch-belt | analytic | pass | | belt: convex hull of circles R20 @ (0,0) and r10 @ (50,0) (external tangents, sin a = (R-r)/d) minus the d8 hole at (50,0), extruded 5 as one body; hull area = (R+r) sqrt(d^2-(R-r)^2) + R^2(pi+2a)/2 + r^2(pi-2a)/2; moments by Green's theorem over the line/arc boundary | |
@@ -43,7 +45,7 @@ No expected value comes from a commercial CAD product.
| 28-blind-pocket-fillets | freecad 1.1.4 | pass | 0.009 / 0.0015 (worst at 60.0, 15.1, 13.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, Part makeFillet | |
| 29-two-sided-and-partial-revolve | analytic | pass | | block [-20,20]x[-25,10]x[0,20] minus the 270 deg revolve (from +X towards +Y) of the 8x8 square r 4..12, z 12..20; the removed section is the annular sector minus the circular segment y > 10 (R^2 acos(d/R) - d sqrt(R^2-d^2), y-moment 2/3 (R^2-d^2)^1.5) that lies outside the block; sector moments (R^3-r^3)/3 (sin, 1-cos); A = box - top opening + floor - front-face notch 2 sqrt(44) x 8 + inner wall r th h + outer wall (th - pi + 2 asin(5/6)) R h + 2 end caps | |
| 30-fillet-after-shell | freecad 1.1.4 | pass | 0.008 / 0.0025 (worst at 56.3, 35.7, 3.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, makeThickness shell, Part makeFillet | |
| 31-fillet-l-all-edges | freecad 1.1.4 | pass | 0.009 / 0.0018 (worst at 7.9, 38.6, 18.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet | |
| 31-fillet-l-all-edges | freecad 1.1.4 | pass | 0.009 / 0.0018 (worst at 6.0, 38.6, 19.9) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet | |
| 32-boss-plate-fillets | analytic | pass | | plate 60x60x8 + boss (r10, z 8..23) with R3 concave base and R2 convex top fillets taken as one solid of revolution: V, z-moment and lateral area from Pappus/Green on its (r,z) profile (quarter-circle fillet arcs); plate top loses the r13 disc | |
| 33-fillet-tangent-chain | analytic | pass | | obround (R8, centres 40 apart) x 10 minus R2 fillet spandrel R^2(1-pi/4) and 1 mm chamfer triangle swept round the whole loop (Pappus: section x path of its centroid, inset R(10-3pi)/(3(4-pi)) resp. c/3); surfaces likewise (fillet arc centroid inset R-2R/pi) | |
| 34-chamfer-then-fillet | freecad 1.1.4 | pass | 0.009 / 0.0022 (worst at 38.5, 5.6, 19.9) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeChamfer, Part makeFillet | |
@@ -51,7 +53,7 @@ No expected value comes from a commercial CAD product.
| 36-corner-blend-two-radii | freecad 1.1.4 | pass | 0.012 / 0.0026 (worst at 45.4, 39.2, 26.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part makeFillet | |
| 37-pyramid-apex-blend | freecad 1.1.4 | pass | 0.020 / 0.0029 (worst at -0.0, 0.0, 28.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft, Part makeFillet | |
| 38-coincident-cylinders | analytic | pass | | union of r10 z0..30 and r10 z20..50 = cylinder r10 h50: V = pi r^2 h, A = 2 pi r h + 2 pi r^2 | |
| 39-tee-pipe | freecad 1.1.4 | pass | 0.008 / 0.0014 (worst at -9.7, 2.4, 40.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | |
| 39-tee-pipe | freecad 1.1.4 | pass | 0.008 / 0.0014 (worst at 5.4, 9.1, 11.9) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | |
| 40-box-sphere-torus | analytic | pass | | box 60x60x10 (z -10..0) + upper hemisphere r12 + spherical cap h2 below z=-10 (V = pi h^2(3R-h)/3, A = 2 pi R h) - lower half torus R22 r4 (Pappus: V = pi r^2/2 * 2 pi R, centroid -4r/3pi, A = pi r * 2 pi R); top loses disc r12 and annulus 18..26, bottom loses disc r sqrt(44) | faces: bottom, 4 sides, top split by the groove into an annulus (r 12..18) and the outer part, upper sphere, lower cap, torus |
| 41-tube-radial-holes | freecad 1.1.4 | pass | 0.010 / 0.0012 (worst at 1.9, 19.9, 24.8) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied) | |
| 42-overlapping-patterns | freecad 1.1.4 | pass | 0.009 / 0.0010 (worst at -1.0, -10.1, 2.2) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude cut, pattern (tools transformed and re-applied), Part extrude cut, pattern (tools transformed and re-applied) | |
@@ -63,7 +65,7 @@ No expected value comes from a commercial CAD product.
| 48-pulley | analytic | pass | | revolve of the 12-segment (r,z) polygon about Z: V = 2 pi int r dA, Mz = 2 pi int r z dA, A = 2 pi int r ds (Pappus) | |
| 49-helical-sweep | analytic | pass | | true helix coil (screw motion of the axial d4 section, R20, pitch 8, 3 turns): V = turns * 2 pi R * pi a^2; lateral A = turns * 2 pi * int sqrt(r^2 + (p/2pi)^2 (dr/ds)^2) ds (periodic trapezoid, exact to round-off) + two end discs; COM z = pitch*turns/2 | |
| 50-sweep-tight-bend | analytic | pass | | tube r5 along line 20 + R5 quarter bend + line 20: V = pi r^2 L, A = 2 pi r L + 2 pi r^2 (Pappus, valid while bend radius >= r; here equal so the inner side pinches to a line); bend centroid at (R^2 + r^2/4)/R * sinc(pi/4) along the bisector | |
| 51-loft-rect-circle-offset | freecad 1.1.4 | pass | 0.025 / 0.0030 (worst at -11.7, 15.0, 0.4) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | |
| 51-loft-rect-circle-offset | freecad 1.1.4 | pass | 0.033 / 0.0028 (worst at 17.5, -14.9, 0.5) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part.makeLoft | |
| 52-circular-pattern-fillet | analytic | pass | | disc r40 h8 + 6 x (boss r6 h10 with R2 concave base fillet as a solid of revolution, Pappus/Green on its (r,z) profile) at radius 28; disc top loses 6 discs r8 | |
| 53-path-pattern | analytic | pass | | plate 120x60x5 minus 7 through holes d6 at arc lengths k*17.875031 along the arc through (100,15),(60,45),(20,15) (centre (60,10/3), R 125/3), translated copies | |
| 54-mirror-body-fillets | analytic | pass | | prism h10 of the 60x40 rectangle with four R5 corner rounds and two d8 holes at (+-15,20): V = A h, area = 2A + perimeter*h | |
@@ -77,7 +79,7 @@ No expected value comes from a commercial CAD product.
| 62-heat-sink | analytic | pass | | base 60x50x5 + 15 fins 1.5x50x25 at x = 2 + 4k: sums of boxes; fin ends coplanar with the base ends | faces: bottom, x=0, x=60, y=0 and y=50 (each merged with the fin ends), 16 base-top strips, 15x3 fin faces |
| 63-hinge-knuckle | freecad 1.1.4 | pass | 0.008 / 0.0016 (worst at 28.0, 36.9, 4.7) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Part extrude, Part extrude join, Part extrude cut | |
| 64-sketch-tangent-arc-chain | analytic | pass | | solved sketch: 80x20 rectangle whose top is 4 tangent R12 arcs on 20 mm chords alternating up/down (net area 1600); prism h3: V = A h, area = 2A + perimeter*h, Green's theorem for centroid | |
| 65-sketch-gear-tooth | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at -6.5, 39.5, 0.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Sketcher-solved sketch, Part extrude | |
| 65-sketch-gear-tooth | freecad 1.1.4 | pass | 0.008 / 0.0017 (worst at 6.5, 39.5, 8.0) | FreeCAD (OpenCASCADE) build of recipe.json by tools/freecad-oracle/build.py: Sketcher-solved sketch, Part extrude | |
| 66-sketch-linkage | analytic | pass | | solved sketch: hull of circles R12 at (0,0) and R8 at (60,0) (outer tangent lines, cos a = 4/60) minus holes d12, d8 (concentric) and d4 at (30,+-4) (symmetry keeps radii equal); prism h6 | |
| 67-sm-base-flange | analytic | pass | | plate 100 x 60 x t, t = 2.5 (Steel (mm) rule), thickness along +Z: V = 100*60*t, A = 2(ab+bc+ca) | |
| 68-sm-edge-flange | analytic | pass | | extrusion along x (100) of the YZ section: base y 5..60 x t; 90 deg bend annulus R 2.5..5 about (y 5, z 5); flange y 0..2.5, z 5..20. Setback (R+t)tan45 = 5 (inside bend position, outer-face height datum: outer face of flange reaches z = 20); V = L*A_sec, A = 2 A_sec + L P_sec | trim/setback and height datum are SolidCraft's documented edge-flange conventions (sheet.rs add_flanges) |
@@ -96,6 +98,13 @@ No expected value comes from a commercial CAD product.
| 81-fillet-spline-radius | analytic | pass | | box 40x30x20 with its top front edge (y=0, z=20) rounded at a radius that is a clamped cubic B-spline in u = x/40, knots 0,0,0,0,0.25,0.6,1,1,1,1, control radii 2, 5, 1.5, 4, 1, 3 (r(0) = 2, r(40) = 3). Sections are quarter rounds; V, moments and areas integrated along the edge, exactly per knot span | |
| 82-thin-extrude-frame | analytic | pass | | square frame: the lines' 40 mm square walled 3 mm inside and 1 mm outside (mitred corners) is 42 x 42 less 34 x 34, 10 mm tall: V = (42^2 - 34^2) * 10, A = 2 (42^2 - 34^2) + 4 * 42 * 10 + 4 * 34 * 10 | |
| 83-boundary-loop-thicken | analytic | pass | | region = 10 x 10 square + half disc r 5 on its top side: A = 100 + 12.5 pi, centroid by moments (half disc's at 4r / (3 pi) above its diameter); slab of thickness 2 over it (symmetric about z = 0): V = 2A, area = 2A + 2 (30 + 5 pi) | |
| 84-plate-two-bores | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
| 85-plate-width-100 | analytic | pass | | Plate L=100, W=100, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
| 86-plate-first-bore-12 | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø12; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
| 87-plate-bore1-deleted | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
| 88-plate-four-bores | analytic | pass | | Plate L=150, W=80, T=10 mm; Bore1 at (15,15) Ø8; Bore2 at (135,15) Ø8; Bore3 at (15,65) Ø8; Bore4 at (135,65) Ø8. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular | |
| 94-plate-four-rounded-corners | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: four exterior vertical R10 corner rounds; four corner spandrels subtract volume T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); rounded perimeter P=2(L+W)-8R+2pi*R | |
| 95-plate-rounded-top-chamfer | analytic | pass | | Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: four exterior vertical R10 corner rounds; full upper tangent loop 2 mm chamfer; four corner spandrels subtract volume T*(4-pi)*R² at symmetric centroid (L/2,W/2,T/2); rounded perimeter P=2(L+W)-8R+2pi*R; upper tangent chamfer subtracts volume Vc=P*c²/2-pi*c³/3 at (L/2,W/2,T-(P*c³/6-pi*c⁴/12)/Vc); area from upper inset face, side strips and sqrt(2)*c*(P-pi*c) bevel surface | |
## Not covered by a reference case
+5 -4
View File
@@ -71,10 +71,11 @@ deployment. The stable Flatpak repository is not automatically updated with nigh
`Cargo.lock` (it changes only our own crates, never dependency versions, and needs no
network). Inspect the diff: only `version = ` lines of workspace crates should change.
SolidCraft does not currently implement `cargo xtask version set`.
3. Fix the design format number: run `scripts/fold-format-pending.sh`. It moves the pending
format changes (`crates/doc/src/format/pending/*.rs`) into the next `crates/doc/src/format/v<N>/`
folder, so the release writes and reads that number for good, and does nothing when nothing
is pending. Commit the move with the version files. (CONTRIBUTING.md, "Changing the design
3. Fix the file format numbers: run `scripts/fold-format-pending.sh`. It moves the pending
format changes of designs (`crates/doc/src/format/pending/*.rs`) and of drawings
(`crates/doc/src/drawing/format/pending/*.rs`) into each one's next `v<N>/` folder, so the
release writes and reads those numbers for good, and does nothing for a family with nothing
pending. Commit the move with the version files. (CONTRIBUTING.md, "Changing the design
file format".)
4. Run `cargo xtask ci` and the packaging checks in `.github/workflows/packaging-lint.yml`.
Commit the version files through the normal review flow and land them on `main`.
+29 -26
View File
@@ -1,33 +1,36 @@
#!/usr/bin/env bash
# Release step: give the pending design-format changes their number for good.
# Release step: give the pending file-format changes their number for good.
#
# Moves every change in crates/doc/src/format/pending/ into crates/doc/src/format/v<N>/, where N
# is the number development builds have been writing for them (the last folded format + 1).
# Commit the result with the release's version bump. Nothing to do when nothing is pending.
# See crates/doc/src/format/pending/README.md.
# For each family of files in crates/doc/build.rs's FAMILIES (designs in crates/doc/src/format/,
# drawings in crates/doc/src/drawing/format/), moves every change in its pending/ folder into its
# v<N>/, where N is the number development builds have been writing for them (the family's last
# folded format + 1). Commit the result with the release's version bump. Nothing to do for a family
# with nothing pending. See crates/doc/src/format/pending/README.md.
set -euo pipefail
root=$(git rev-parse --show-toplevel)
dir="$root/crates/doc/src/format"
last=$(sed -n 's/^const HAND_WRITTEN: u32 = \([0-9]*\);/\1/p' "$root/crates/doc/build.rs")
[ -n "$last" ] || { echo "HAND_WRITTEN not found in crates/doc/build.rs" >&2; exit 1; }
for d in "$dir"/v*/; do
[ -d "$d" ] || continue
n=${d%/}
n=${n##*/v}
if [ "$n" -gt "$last" ]; then last=$n; fi
done
families=$(grep -o 'Family { folder: "[^"]*", base: [0-9]*' "$root/crates/doc/build.rs" | sed 's/.*folder: "\([^"]*\)", base: \([0-9]*\)/\1 \2/' || true)
[ -n "$families" ] || { echo "FAMILIES not found in crates/doc/build.rs" >&2; exit 1; }
shopt -s nullglob
pending=("$dir"/pending/*.rs)
if [ ${#pending[@]} -eq 0 ]; then
echo "No pending format changes: this release writes format $last."
exit 0
fi
next=$((last + 1))
mkdir -p "$dir/v$next"
git -C "$root" mv "${pending[@]}" "$dir/v$next/"
echo "Format $next is now fixed, with ${#pending[@]} change(s):"
for f in "${pending[@]}"; do echo " ${f##*/}"; done
echo "Commit crates/doc/src/format/ with the version bump."
folded=0
while read -r folder last; do
dir="$root/crates/doc/$folder"
for d in "$dir"/v*/; do
n=${d%/}
n=${n##*/v}
if [ "$n" -gt "$last" ]; then last=$n; fi
done
pending=("$dir"/pending/*.rs)
if [ ${#pending[@]} -eq 0 ]; then
echo "No pending format changes in $folder: this release writes format $last."
continue
fi
next=$((last + 1))
mkdir -p "$dir/v$next"
git -C "$root" mv "${pending[@]}" "$dir/v$next/"
echo "Format $next of $folder is now fixed, with ${#pending[@]} change(s):"
for f in "${pending[@]}"; do echo " ${f##*/}"; done
folded=$((folded + 1))
done <<< "$families"
[ "$folded" -eq 0 ] || echo "Commit crates/doc/src/ with the version bump."
+14 -5
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Checks the design-format change files (crates/doc/src/format/): two branches that each add a
# pending change merge without a conflict, and the release fold moves both into the next number.
# A drawing-format change (crates/doc/src/drawing/format/) folds into the drawing's own next number.
# The order they apply in and the upgrades themselves are tested in crates/doc/src/format.rs.
set -euo pipefail
@@ -8,11 +9,12 @@ root=$(git rev-parse --show-toplevel)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
repo="$tmp/repo"
mkdir -p "$repo/crates/doc/src" "$repo/scripts"
mkdir -p "$repo/crates/doc/src/drawing" "$repo/scripts"
cp -r "$root/crates/doc/src/format" "$repo/crates/doc/src/"
cp -r "$root/crates/doc/src/drawing/format" "$repo/crates/doc/src/drawing/"
cp "$root/crates/doc/build.rs" "$repo/crates/doc/"
cp "$root/scripts/fold-format-pending.sh" "$repo/scripts/"
rm -f "$repo"/crates/doc/src/format/pending/*.rs # start from a release: nothing pending
rm -f "$repo"/crates/doc/src/format/pending/*.rs "$repo"/crates/doc/src/drawing/format/pending/*.rs # start from a release: nothing pending
git() { command git -C "$repo" -c user.name=test -c user.email=test@example.com "$@"; }
git init -q -b main
git add -A
@@ -20,12 +22,13 @@ git commit -qm base
change() {
git switch -q -c "$1" main
printf '//! %s\n' "$1" > "$repo/crates/doc/src/format/pending/$1.rs"
printf '//! %s\n' "$1" > "$repo/crates/doc/src/${2:-format}/pending/$1.rs"
git add -A
git commit -qm "$1"
}
change b-second
change a-first
change c-drawing drawing/format
git switch -q main
if ! tree=$(git merge-tree --write-tree a-first b-second); then
@@ -36,12 +39,18 @@ git merge -q --no-edit a-first b-second >/dev/null
listed=$(git ls-tree --name-only HEAD crates/doc/src/format/pending/ | grep '\.rs$' | sed 's#.*/##' | tr '\n' ' ')
[ "$listed" = "a-first.rs b-second.rs " ] || { echo "FAIL: merged pending changes are: $listed" >&2; exit 1; }
[ "$tree" = "$(git rev-parse HEAD^{tree})" ] || { echo "FAIL: merge-tree and merge disagree" >&2; exit 1; }
git merge -q --no-edit c-drawing >/dev/null
before=$(ls -d "$repo"/crates/doc/src/format/v*/ 2>/dev/null | wc -l)
drawings=$(find "$repo/crates/doc/src/drawing/format" -mindepth 1 -maxdepth 1 -type d -name 'v*' | wc -l)
(cd "$repo" && bash scripts/fold-format-pending.sh) > "$tmp/fold.log"
after=$(ls -d "$repo"/crates/doc/src/format/v*/ | sort -V | tail -1)
[ "$(ls -d "$repo"/crates/doc/src/format/v*/ | wc -l)" -eq $((before + 1)) ] || { echo "FAIL: the fold made no new version" >&2; exit 1; }
[ "$(ls "$after")" = "$(printf 'a-first.rs\nb-second.rs')" ] || { echo "FAIL: the fold moved $(ls "$after")" >&2; exit 1; }
[ -z "$(ls "$repo/crates/doc/src/format/pending/" | grep '\.rs$' || true)" ] || { echo "FAIL: pending changes left after the fold" >&2; exit 1; }
(cd "$repo" && bash scripts/fold-format-pending.sh) | grep -q "No pending format changes" || { echo "FAIL: a second fold did something" >&2; exit 1; }
echo "ok: pending format changes merge cleanly and fold into $(basename "$after")"
# A drawing's formats start at 1, so its folded ones are v2/, v3/ and so on: the next is its own, never the design's.
drawing_next="$repo/crates/doc/src/drawing/format/v$((drawings + 2))"
[ "$(ls "$drawing_next" 2>/dev/null)" = c-drawing.rs ] || { echo "FAIL: the drawing change did not fold into ${drawing_next##*/}/" >&2; exit 1; }
second=$(cd "$repo" && bash scripts/fold-format-pending.sh)
grep -q "No pending format changes" <<< "$second" && ! grep -q "now fixed" <<< "$second" || { echo "FAIL: a second fold did something" >&2; exit 1; }
echo "ok: pending format changes merge cleanly and fold into $(basename "$after"), a drawing's into ${drawing_next##*/}"
@@ -0,0 +1,25 @@
{
"source": "analytic",
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
"body_count": 1,
"volume_mm3": 156858.4073464102,
"area_mm2": 24142.477796076935,
"center_of_mass": [
50.20028207009981,
40.1001410350499,
10
],
"bbox": {
"min": [
0,
0,
0
],
"max": [
100,
80,
20
]
},
"faces": 8
}
+335
View File
@@ -0,0 +1,335 @@
{
"id": "84-plate-two-bores",
"name": "Workshop plate with two independent Ø10 bores",
"description": "Parameterized rectangular workshop plate 100 x 80 x 20 mm with 2 independent through bores. World origin at (0,0,0).",
"exercises": [
"extrude",
"hole_through_all",
"drawing_w23_acceptance"
],
"units": {
"length": "mm",
"angle": "deg",
"area": "mm2",
"volume": "mm3"
},
"conventions": {
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
"extrude_direction": "positive = along sketch plane normal unless stated",
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
},
"parameters": [
{
"name": "length",
"expression": "100 mm",
"unit": "mm"
},
{
"name": "width",
"expression": "80 mm",
"unit": "mm"
},
{
"name": "thickness",
"expression": "20 mm",
"unit": "mm"
},
{
"name": "bore1_d",
"expression": "10 mm",
"unit": "mm"
},
{
"name": "bore2_d",
"expression": "10 mm",
"unit": "mm"
}
],
"features": [
{
"op": "sketch",
"name": "StockSketch",
"plane": {
"ref": "XY",
"origin": [
0,
0,
0
],
"x_dir": [
1,
0,
0
],
"y_dir": [
0,
1,
0
],
"normal": [
0,
0,
1
]
},
"entities": [
{
"type": "line",
"start": {
"at": [
0,
0
]
},
"end": {
"at": [
100,
0
]
},
"id": "l1"
},
{
"type": "line",
"start": {
"ref": "l1.end",
"at": [
100,
0
]
},
"end": {
"at": [
100,
80
]
},
"id": "l2"
},
{
"type": "line",
"start": {
"ref": "l2.end",
"at": [
100,
80
]
},
"end": {
"at": [
0,
80
]
},
"id": "l3"
},
{
"type": "line",
"start": {
"ref": "l3.end",
"at": [
0,
80
]
},
"end": {
"ref": "l1.start",
"at": [
0,
0
]
},
"id": "l4"
},
{
"type": "circle",
"id": "bore_circle_1",
"center": {
"at": [
20,
20
]
},
"radius": 5,
"construction": true
},
{
"type": "circle",
"id": "bore_circle_2",
"center": {
"at": [
60,
50
]
},
"radius": 5,
"construction": true
}
],
"constraints": [
{
"type": "coincident",
"refs": [
"l1.start",
"origin"
]
},
{
"type": "horizontal",
"refs": [
"l1"
]
},
{
"type": "vertical",
"refs": [
"l2"
]
},
{
"type": "horizontal",
"refs": [
"l3"
]
},
{
"type": "vertical",
"refs": [
"l4"
]
}
],
"dimensions": [
{
"type": "distance",
"refs": [
"l1.start",
"l1.end"
],
"value": "length",
"unit": "mm",
"orientation": "horizontal"
},
{
"type": "distance",
"refs": [
"l2.start",
"l2.end"
],
"value": "width",
"unit": "mm",
"orientation": "vertical"
},
{
"type": "diameter",
"refs": [
"bore_circle_1"
],
"value": "bore1_d",
"unit": "mm"
},
{
"type": "diameter",
"refs": [
"bore_circle_2"
],
"value": "bore2_d",
"unit": "mm"
}
]
},
{
"op": "extrude",
"name": "Stock",
"operation": "new",
"profiles": [
{
"sketch": "StockSketch",
"loops": [
{
"outer": true,
"curves": [
"l1",
"l2",
"l3",
"l4"
]
}
]
}
],
"extent": {
"type": "distance",
"distance": "thickness",
"direction": "positive"
},
"result_bodies": [
"Plate"
]
},
{
"op": "hole",
"name": "Bore1",
"hole_type": "simple",
"face": {
"type": "plane",
"normal_at_point_on_face": [
0,
0,
1
],
"plane_normal_outward": [
0,
0,
1
],
"point": [
50,
40,
20
]
},
"position": [
20,
20,
20
],
"direction": "into the material (-Z)",
"diameter": "bore1_d",
"extent": "through_all"
},
{
"op": "hole",
"name": "Bore2",
"hole_type": "simple",
"face": {
"type": "plane",
"normal_at_point_on_face": [
0,
0,
1
],
"plane_normal_outward": [
0,
0,
1
],
"point": [
50,
40,
20
]
},
"position": [
60,
50,
20
],
"direction": "into the material (-Z)",
"diameter": "bore2_d",
"extent": "through_all"
}
]
}
@@ -0,0 +1,25 @@
{
"source": "analytic",
"method": "Plate L=100, W=100, T=20 mm; Bore1 at (20,20) Ø10; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
"body_count": 1,
"volume_mm3": 196858.4073464102,
"area_mm2": 28942.477796076935,
"center_of_mass": [
50.15958641014815,
50.23937961522224,
10
],
"bbox": {
"min": [
0,
0,
0
],
"max": [
100,
100,
20
]
},
"faces": 8
}
+335
View File
@@ -0,0 +1,335 @@
{
"id": "85-plate-width-100",
"name": "Workshop plate after width change to 100 mm",
"description": "Parameterized rectangular workshop plate 100 x 100 x 20 mm with 2 independent through bores. World origin at (0,0,0).",
"exercises": [
"extrude",
"hole_through_all",
"drawing_w23_acceptance"
],
"units": {
"length": "mm",
"angle": "deg",
"area": "mm2",
"volume": "mm3"
},
"conventions": {
"world": "right-handed; Z up. XY/XZ/YZ are the origin planes.",
"sketch_frame": "sketch-local 2D coords; world = plane.origin + x*plane.x_dir + y*plane.y_dir (origin in mm). Profile 'loops' list sketch entity ids.",
"point_refs": "'<id>.start' / '<id>.end' / '<id>.center' reuse (share) that existing sketch point; 'at' is its position when referenced.",
"extrude_direction": "positive = along sketch plane normal unless stated",
"selections": "selected faces/edges carry 'point': a world-space point on the face / the edge's parametric midpoint; pick the nearest face/edge to it."
},
"parameters": [
{
"name": "length",
"expression": "100 mm",
"unit": "mm"
},
{
"name": "width",
"expression": "100 mm",
"unit": "mm"
},
{
"name": "thickness",
"expression": "20 mm",
"unit": "mm"
},
{
"name": "bore1_d",
"expression": "10 mm",
"unit": "mm"
},
{
"name": "bore2_d",
"expression": "10 mm",
"unit": "mm"
}
],
"features": [
{
"op": "sketch",
"name": "StockSketch",
"plane": {
"ref": "XY",
"origin": [
0,
0,
0
],
"x_dir": [
1,
0,
0
],
"y_dir": [
0,
1,
0
],
"normal": [
0,
0,
1
]
},
"entities": [
{
"type": "line",
"start": {
"at": [
0,
0
]
},
"end": {
"at": [
100,
0
]
},
"id": "l1"
},
{
"type": "line",
"start": {
"ref": "l1.end",
"at": [
100,
0
]
},
"end": {
"at": [
100,
100
]
},
"id": "l2"
},
{
"type": "line",
"start": {
"ref": "l2.end",
"at": [
100,
100
]
},
"end": {
"at": [
0,
100
]
},
"id": "l3"
},
{
"type": "line",
"start": {
"ref": "l3.end",
"at": [
0,
100
]
},
"end": {
"ref": "l1.start",
"at": [
0,
0
]
},
"id": "l4"
},
{
"type": "circle",
"id": "bore_circle_1",
"center": {
"at": [
20,
20
]
},
"radius": 5,
"construction": true
},
{
"type": "circle",
"id": "bore_circle_2",
"center": {
"at": [
60,
50
]
},
"radius": 5,
"construction": true
}
],
"constraints": [
{
"type": "coincident",
"refs": [
"l1.start",
"origin"
]
},
{
"type": "horizontal",
"refs": [
"l1"
]
},
{
"type": "vertical",
"refs": [
"l2"
]
},
{
"type": "horizontal",
"refs": [
"l3"
]
},
{
"type": "vertical",
"refs": [
"l4"
]
}
],
"dimensions": [
{
"type": "distance",
"refs": [
"l1.start",
"l1.end"
],
"value": "length",
"unit": "mm",
"orientation": "horizontal"
},
{
"type": "distance",
"refs": [
"l2.start",
"l2.end"
],
"value": "width",
"unit": "mm",
"orientation": "vertical"
},
{
"type": "diameter",
"refs": [
"bore_circle_1"
],
"value": "bore1_d",
"unit": "mm"
},
{
"type": "diameter",
"refs": [
"bore_circle_2"
],
"value": "bore2_d",
"unit": "mm"
}
]
},
{
"op": "extrude",
"name": "Stock",
"operation": "new",
"profiles": [
{
"sketch": "StockSketch",
"loops": [
{
"outer": true,
"curves": [
"l1",
"l2",
"l3",
"l4"
]
}
]
}
],
"extent": {
"type": "distance",
"distance": "thickness",
"direction": "positive"
},
"result_bodies": [
"Plate"
]
},
{
"op": "hole",
"name": "Bore1",
"hole_type": "simple",
"face": {
"type": "plane",
"normal_at_point_on_face": [
0,
0,
1
],
"plane_normal_outward": [
0,
0,
1
],
"point": [
50,
50,
20
]
},
"position": [
20,
20,
20
],
"direction": "into the material (-Z)",
"diameter": "bore1_d",
"extent": "through_all"
},
{
"op": "hole",
"name": "Bore2",
"hole_type": "simple",
"face": {
"type": "plane",
"normal_at_point_on_face": [
0,
0,
1
],
"plane_normal_outward": [
0,
0,
1
],
"point": [
50,
50,
20
]
},
"position": [
60,
50,
20
],
"direction": "into the material (-Z)",
"diameter": "bore2_d",
"extent": "through_all"
}
]
}
@@ -0,0 +1,25 @@
{
"source": "analytic",
"method": "Plate L=100, W=80, T=20 mm; Bore1 at (20,20) Ø12; Bore2 at (60,50) Ø10. Box minus independently located through-cylinders: V=LWT-sum(pi*d²*T/4), area=2(LW+LT+WT)+sum(pi*d*T-pi*d²/2), mass moments from exact box minus cylinders. Shape: rectangular",
"body_count": 1,
"volume_mm3": 156167.25696262045,
"area_mm2": 24199.026463841554,
"center_of_mass": [
50.333939643071744,
40.189098352100864,
10
],
"bbox": {
"min": [
0,
0,
0
],
"max": [
100,
80,
20
]
},
"faces": 8
}

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