mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-02 18:16:19 +00:00
Drawings: first landing from the drawings branch (#8249)
* 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> * 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 * 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> * 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> * 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> * 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> * 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> * 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> * 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> * 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> * 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> --------- Co-authored-by: CamWight <91156642+CamWight@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Benny1496 <163034806+Benny1496@users.noreply.github.com> Co-authored-by: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
This commit is contained in:
+3
-1
@@ -268,7 +268,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
|
||||
|
||||
|
||||
+43
-17
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! A drawing (#3890): sheets of paper that draw designs saved in their own files. A drawing is a
|
||||
//! file of its own, `"format": "solidcraft-drawing/N"`, versioned by the same code as designs
|
||||
//! ([`DRAWING`]); format 1 is this record.
|
||||
//!
|
||||
//! - **Sources** are the designs it draws: an id and the design file's path, relative to the
|
||||
//! drawing's folder once the drawing is saved, else as given, with `/` between folders (a linked
|
||||
//! design's [`DesignLink`](crate::DesignLink) rule). What a design was when the drawing last saw
|
||||
//! it is not saved here.
|
||||
//! - **Sheets**: the paper, a copy of its drawing border, the resolution its pictures are rendered
|
||||
//! at, the component it documents, and whether it is left out of the sheet count or of printing.
|
||||
//! The border is a copy of the profile it was made with, never a name, so a later built-in never
|
||||
//! changes a saved drawing.
|
||||
//! - **Units** follow each view's design or are one length unit for the whole drawing. The
|
||||
//! **language** is the content's, a BCP 47 tag chosen when the drawing is made, whatever the
|
||||
//! interface's is. **Properties** follow [`Metadata`]'s rules.
|
||||
//!
|
||||
//! The 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 and at most [`MAX_SOURCES`] sources; names of 1 to
|
||||
//! [`MAX_NAME_BYTES`] bytes; paths of 1 to [`MAX_PATH_BYTES`] bytes, without NUL.
|
||||
//! - Nothing derived is saved, and a drawing always saves as the same bytes: fields in their
|
||||
//! order, maps sorted.
|
||||
//! - A later change to the record is one file in `src/drawing/format/pending/` (its README).
|
||||
//!
|
||||
//! [`Drawing::from_json`] reads a file in this order: at most [`MAX_DRAWING_BYTES`] of text, the
|
||||
//! family's format check, every number in range, the upgrade, the typed record, then
|
||||
//! [`Drawing::validate`].
|
||||
|
||||
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 sheets a drawing may have.
|
||||
pub const MAX_SHEETS: usize = 1000;
|
||||
/// The most designs a drawing may draw.
|
||||
pub const MAX_SOURCES: usize = 64;
|
||||
/// The longest name of a drawing or a sheet, in bytes.
|
||||
pub const MAX_NAME_BYTES: usize = 256;
|
||||
/// The longest path to a design, in bytes.
|
||||
pub const MAX_PATH_BYTES: usize = 4096;
|
||||
/// The longest language tag: what RFC 5646 asks every reader to take (§4.4.1).
|
||||
pub const MAX_LANGUAGE_CHARS: usize = 35;
|
||||
/// The largest drawing read, as JSON text.
|
||||
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>,
|
||||
pub name: String,
|
||||
/// The designs the drawing draws.
|
||||
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>>,
|
||||
}
|
||||
|
||||
/// A design the drawing draws.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Source {
|
||||
#[serde(with = "crate::ids")]
|
||||
pub id: u64,
|
||||
/// The design file: relative to the drawing's folder once the drawing is saved, else as given
|
||||
/// (`/` separates folders).
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// The length unit a drawing gives its measurements in.
|
||||
#[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 from `id_source`.
|
||||
pub fn new(
|
||||
name: &str,
|
||||
language: &str,
|
||||
sheet: &str,
|
||||
paper: drawing_sheet::Sheet,
|
||||
border: Option<DrawingBorderProfile>,
|
||||
id_source: &mut IdSource,
|
||||
) -> Result<Drawing> {
|
||||
let id = id_source.next(&BTreeSet::new())?;
|
||||
let first = Sheet { id, name: sheet.into(), sheet: paper, border, dpi: DEFAULT_DPI, documents: None, excluded: Excluded::default() };
|
||||
let drawing = Drawing {
|
||||
format: DRAWING.format_string(DRAWING.current()),
|
||||
format_pending: DRAWING.pending_names(),
|
||||
name: name.into(),
|
||||
sources: Vec::new(),
|
||||
properties: Metadata::default(),
|
||||
units: Units::default(),
|
||||
language: language.into(),
|
||||
sheets: vec![Arc::new(first)],
|
||||
};
|
||||
drawing.validate()?;
|
||||
Ok(drawing)
|
||||
}
|
||||
|
||||
/// Read a drawing file's JSON (any format this build knows; older ones are upgraded). A file
|
||||
/// that breaks a rule of the record is damaged and refused, never patched.
|
||||
pub fn from_json(s: &str) -> Result<Drawing> {
|
||||
if s.len() > MAX_DRAWING_BYTES {
|
||||
return Err(DocError::Invalid(format!("drawing too large (at most {} MiB)", MAX_DRAWING_BYTES >> 20)));
|
||||
}
|
||||
let mut v: Value = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("not a SolidCraft drawing: {e}")))?;
|
||||
let (version, applied) = DRAWING.take_version(&mut v)?;
|
||||
DRAWING.check_numbers(&v, "properties")?;
|
||||
DRAWING.upgrade(&mut v, version, applied.as_deref())?;
|
||||
let d: Drawing = serde_json::from_value(v).map_err(damaged)?;
|
||||
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> {
|
||||
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())
|
||||
}
|
||||
|
||||
/// Every object's id, of every kind: what a new id must differ from. Each later kind adds its
|
||||
/// own here.
|
||||
fn ids(&self) -> impl Iterator<Item = u64> {
|
||||
self.sources.iter().map(|s| s.id).chain(self.sheets.iter().map(|s| s.id))
|
||||
}
|
||||
|
||||
/// The record's rules (the module's notes): what reading a file checks, and what every edit
|
||||
/// keeps.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
require((1..=MAX_SHEETS).contains(&self.sheets.len()), || format!("a drawing has 1 to {MAX_SHEETS} sheets"))?;
|
||||
require(self.sources.len() <= MAX_SOURCES, || format!("a drawing draws at most {MAX_SOURCES} designs"))?;
|
||||
check_name(&self.name, "the drawing's")?;
|
||||
require(is_language_tag(&self.language), || {
|
||||
format!("the language is not a BCP 47 tag of 2 to {MAX_LANGUAGE_CHARS} characters (such as `en` or `en-GB`)")
|
||||
})?;
|
||||
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 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 source in &self.sources {
|
||||
let path = &source.path;
|
||||
require(!path.is_empty() && path.len() <= MAX_PATH_BYTES && !path.contains('\0'), || {
|
||||
format!("source {}: a design's path is 1 to {MAX_PATH_BYTES} bytes, without NUL", ids::encode(source.id))
|
||||
})?;
|
||||
}
|
||||
for sheet in &self.sheets {
|
||||
self.check_sheet(sheet).map_err(|e| DocError::Invalid(format!("sheet {}: {e}", ids::encode(sheet.id))))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `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 file saves 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,553 @@
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::drawing_sheet::{Margins, Orientation, PaperSize, SheetSize};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The seed every test drawing's ids come from.
|
||||
const SEED: u64 = 3890;
|
||||
|
||||
/// A profile of its own: ISO 5457's filing margin and trimming marks, which the built-in leaves off.
|
||||
fn own_border() -> DrawingBorderProfile {
|
||||
DrawingBorderProfile { filing_margin: true, trimming_mark_mm: Some([10.0, 5.0]), ..DrawingBorderProfile::iso() }
|
||||
}
|
||||
|
||||
/// Three designs on two sheets: the first sheet (A3, the ISO border) documents component 7 of the
|
||||
/// first design; the second (A4 portrait, a border of its own, 600 dpi, left out of printing)
|
||||
/// documents the second design. The third design is on no sheet yet.
|
||||
fn plate() -> Drawing {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Plate assembly", "en-GB", "Assembly", a3, Some(DrawingBorderProfile::iso()), &mut id_source).unwrap();
|
||||
for path in ["plate.sc", "../parts/bolt.sc", "bracket.sc"] {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id, path: path.into() });
|
||||
}
|
||||
let (plate_id, bolt_id) = (d.sources[0].id, d.sources[1].id);
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sheets.push(Arc::new(Sheet {
|
||||
id,
|
||||
name: "Bolt".into(),
|
||||
sheet: drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Portrait),
|
||||
border: Some(own_border()),
|
||||
dpi: 600.0,
|
||||
documents: Some(SheetComponent { source: bolt_id, component: 0 }),
|
||||
excluded: Excluded { count: false, print: true },
|
||||
}));
|
||||
Arc::make_mut(&mut d.sheets[0]).documents = Some(SheetComponent { source: plate_id, component: 7 });
|
||||
d.properties.set(crate::metadata::Owner::Design, "author", json!("Jane Doe")).unwrap();
|
||||
d.units = Units::Fixed { unit: "in".into() };
|
||||
d.validate().unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// One A4 sheet that documents nothing, no sources: the base the caps grow from.
|
||||
fn bare() -> Drawing {
|
||||
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
|
||||
Drawing::new("Bare", "en", "Sheet", a4, None, &mut IdSource::seeded(SEED)).unwrap()
|
||||
}
|
||||
|
||||
fn file(d: &Drawing) -> Value {
|
||||
serde_json::from_str(&d.to_json().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn read(file: &Value) -> Result<Drawing> {
|
||||
Drawing::from_json(&file.to_string())
|
||||
}
|
||||
|
||||
/// Why `file` is refused, or "read".
|
||||
fn refusal(file: &Value) -> String {
|
||||
read(file).map_or_else(|e| e.to_string(), |_| "read".into())
|
||||
}
|
||||
|
||||
/// Why `d`'s file, changed by `edit`, is refused, or "read".
|
||||
fn refused(d: &Drawing, edit: impl FnOnce(&mut Value)) -> String {
|
||||
let mut v = file(d);
|
||||
edit(&mut v);
|
||||
refusal(&v)
|
||||
}
|
||||
|
||||
/// A drawing reads back as it was saved, and save, open and save again writes the same bytes, as
|
||||
/// does another run from the same seed. The file holds each field under the record's name for it,
|
||||
/// ids as their 13 characters, and no counter.
|
||||
#[test]
|
||||
fn a_drawing_reads_back_as_saved_and_saves_the_same_bytes_again() {
|
||||
let d = plate();
|
||||
let saved = d.to_json().unwrap();
|
||||
let reopened = Drawing::from_json(&saved).unwrap();
|
||||
assert_eq!(reopened, d);
|
||||
assert_eq!(reopened.to_json().unwrap(), saved);
|
||||
assert_eq!(plate().to_json().unwrap(), saved, "a seeded run makes the same drawing");
|
||||
let v = file(&d);
|
||||
let [plate_id, bolt_id, bracket_id] = [0, 1, 2].map(|i| ids::encode(d.sources[i].id));
|
||||
let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id));
|
||||
assert_eq!(assembly_id, "1ge30wkvq38r0", "the seed's first id, as splitmix64 gives it");
|
||||
assert_eq!(v["format"], DRAWING.format_string(DRAWING.current()));
|
||||
assert_eq!(v.get("format_pending").is_some(), !DRAWING.pending_names().is_empty());
|
||||
assert_eq!(v.get("next_id"), None);
|
||||
assert_eq!((&v["name"], &v["language"]), (&json!("Plate assembly"), &json!("en-GB")));
|
||||
assert_eq!(
|
||||
v["sources"],
|
||||
json!([{"id": plate_id, "path": "plate.sc"}, {"id": bolt_id, "path": "../parts/bolt.sc"}, {"id": bracket_id, "path": "bracket.sc"}])
|
||||
);
|
||||
assert_eq!(v["properties"], json!({"author": "Jane Doe"}));
|
||||
assert_eq!(v["units"], json!({"fixed": {"unit": "in"}}));
|
||||
assert_eq!((&v["sheets"][0]["id"], &v["sheets"][0]["dpi"]), (&json!(assembly_id), &json!(300.0)));
|
||||
assert_eq!(v["sheets"][0]["sheet"], json!({"size": {"standard": "a3"}, "orientation": "landscape"}));
|
||||
assert_eq!(v["sheets"][0]["documents"], json!({"source": plate_id, "component": 7}));
|
||||
assert_eq!(v["sheets"][0]["excluded"], json!({"count": false, "print": false}));
|
||||
assert_eq!(
|
||||
v["sheets"][1],
|
||||
json!({
|
||||
"id": bolt_sheet_id, "name": "Bolt", "sheet": {"size": {"standard": "a4"}, "orientation": "portrait"},
|
||||
"border": serde_json::to_value(own_border()).unwrap(), "dpi": 600.0,
|
||||
"documents": {"source": bolt_id, "component": 0}, "excluded": {"count": false, "print": true}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/// A hand-written file may leave out what has a default, and reads as a new drawing has it; the
|
||||
/// rest is needed.
|
||||
#[test]
|
||||
fn a_file_may_leave_out_what_has_a_default() {
|
||||
let minimal = json!({
|
||||
"format": DRAWING.format_string(1),
|
||||
"name": "Bracket",
|
||||
"sources": [],
|
||||
"language": "de",
|
||||
"sheets": [{"id": "0000000000001", "name": "Blatt 1", "sheet": {"size": {"standard": "a3"}}}]
|
||||
});
|
||||
let d = read(&minimal).unwrap();
|
||||
assert_eq!((d.sheets[0].id, &d.units, d.properties.is_empty()), (1, &Units::FollowModel, true));
|
||||
let sheet = &d.sheets[0];
|
||||
assert_eq!(sheet.sheet, drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape));
|
||||
assert_eq!((sheet.border, sheet.dpi, sheet.documents, sheet.excluded), (None, DEFAULT_DPI, None, Excluded::default()));
|
||||
// The format as this build writes it, however the file spelt it.
|
||||
let mut spelt = minimal.clone();
|
||||
spelt["format"] = json!("solidcraft-drawing/+1");
|
||||
assert_eq!(read(&spelt).unwrap().format, DRAWING.format_string(DRAWING.current()));
|
||||
for key in ["name", "sources", "language", "sheets"] {
|
||||
let mut v = minimal.clone();
|
||||
v.as_object_mut().unwrap().remove(key);
|
||||
let why = refusal(&v);
|
||||
assert!(why.starts_with("damaged drawing: ") && why.contains(&format!("missing field `{key}`")), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cap on both sides: sheets, sources, the bytes of paths and names (two-byte characters too).
|
||||
#[test]
|
||||
fn every_cap_holds_on_both_sides() {
|
||||
let base = file(&bare());
|
||||
let with = |key: &str, value: Value| {
|
||||
let mut v = base.clone();
|
||||
v[key] = value;
|
||||
v
|
||||
};
|
||||
let sheet = base["sheets"][0].clone();
|
||||
let sheets = |n: u64| {
|
||||
let one = |id: u64| {
|
||||
let mut s = sheet.clone();
|
||||
s["id"] = json!(ids::encode(id));
|
||||
s
|
||||
};
|
||||
Value::Array((1..=n).map(one).collect())
|
||||
};
|
||||
assert_eq!(read(&with("sheets", sheets(1000))).unwrap().sheets.len(), 1000);
|
||||
for n in [0, 1001] {
|
||||
assert_eq!(refusal(&with("sheets", sheets(n))), "damaged drawing: a drawing has 1 to 1000 sheets", "{n} sheets");
|
||||
}
|
||||
// Sources from id 1: the sheet's id is the seed's, far from them.
|
||||
let sources = |n: u64| Value::Array((1..=n).map(|id| json!({"id": ids::encode(id), "path": format!("part{id}.sc")})).collect());
|
||||
assert_eq!(read(&with("sources", sources(64))).unwrap().sources.len(), 64);
|
||||
assert_eq!(refusal(&with("sources", sources(65))), "damaged drawing: a drawing draws at most 64 designs");
|
||||
let path = |p: &str| refusal(&with("sources", json!([{"id": ids::encode(1), "path": p}])));
|
||||
for fits in ["a".repeat(4096), "é".repeat(2048), "/".into()] {
|
||||
assert_eq!(path(fits.as_str()), "read", "{} bytes", fits.len());
|
||||
}
|
||||
let rule = "damaged drawing: source 0000000000001: a design's path is 1 to 4096 bytes, without NUL";
|
||||
for broken in ["a".repeat(4097), "é".repeat(2048) + "a", String::new(), "plate\0.sc".into()] {
|
||||
assert_eq!(path(broken.as_str()), rule, "{} bytes", broken.len());
|
||||
}
|
||||
let names = |name: &str| {
|
||||
let mut sheet_named = base.clone();
|
||||
sheet_named["sheets"][0]["name"] = json!(name);
|
||||
(refusal(&with("name", json!(name))), refusal(&sheet_named))
|
||||
};
|
||||
for fits in ["x".repeat(256), "é".repeat(128), "A".into(), "Blatt 1 – Übersicht".into()] {
|
||||
assert_eq!(names(fits.as_str()), ("read".to_string(), "read".to_string()), "{} bytes", fits.len());
|
||||
}
|
||||
let rule = "name is 1 to 256 bytes, not only spaces, without control characters";
|
||||
let sheet_id = ids::encode(bare().sheets[0].id);
|
||||
for broken in ["x".repeat(257), "é".repeat(128) + "a", String::new(), " ".into(), "two\nlines".into(), "bell\u{7}".into(), "nul\0".into()] {
|
||||
let expected = (format!("damaged drawing: the drawing's {rule}"), format!("damaged drawing: sheet {sheet_id}: its {rule}"));
|
||||
assert_eq!(names(broken.as_str()), expected, "{broken:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The content language is a well-formed BCP 47 tag of 2 to 35 characters.
|
||||
#[test]
|
||||
fn the_language_is_a_bcp_47_tag_of_2_to_35_characters() {
|
||||
let base = file(&bare());
|
||||
let why = |tag: &str| {
|
||||
let mut v = base.clone();
|
||||
v["language"] = json!(tag);
|
||||
refusal(&v)
|
||||
};
|
||||
let longest = "en-abcdefgh-abcdefgh-abcdefgh-abcde";
|
||||
assert_eq!(longest.len(), 35);
|
||||
for tag in ["en", "de", "ast", "en-GB", "EN-gb", "zh-Hans-CN", "sr-Latn-RS", "es-419", longest] {
|
||||
assert_eq!(why(tag), "read", "{tag}");
|
||||
}
|
||||
let rule = "damaged drawing: the language is not a BCP 47 tag of 2 to 35 characters (such as `en` or `en-GB`)";
|
||||
let too_long = format!("{longest}f");
|
||||
let broken =
|
||||
["e", "", too_long.as_str(), "en_GB", "en-", "-en", "en--GB", "e1", "x-private", "en-abcdefghi", "abcdefghi", "日本", "en GB", "en\n"];
|
||||
for tag in broken {
|
||||
assert_eq!(why(tag), rule, "{tag:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed unit is a length unit of the expression unit table, written as the table writes it.
|
||||
#[test]
|
||||
fn a_fixed_unit_is_a_length_unit_of_the_expression_table() {
|
||||
let base = file(&bare());
|
||||
let units = |units: Value| {
|
||||
let mut v = base.clone();
|
||||
v["units"] = units;
|
||||
read(&v).map(|d| d.units)
|
||||
};
|
||||
for (unit, kind, _) in expr::UNITS {
|
||||
assert_eq!(units(json!({"fixed": {"unit": unit}})).is_ok(), kind == expr::Kind::Length, "{unit}");
|
||||
}
|
||||
assert_eq!(units(json!({"fixed": {"unit": "in"}})).unwrap(), Units::Fixed { unit: "in".into() });
|
||||
assert_eq!(units(json!("follow_model")).unwrap(), Units::FollowModel);
|
||||
let rule = "damaged drawing: the drawing's unit is not a length unit (such as `mm` or `in`)";
|
||||
for unit in ["furlong", "deg", "°", "none", "", " in", "in ", "IN"] {
|
||||
assert_eq!(units(json!({"fixed": {"unit": unit}})).unwrap_err().to_string(), rule, "{unit:?}");
|
||||
}
|
||||
for broken in [json!("follow_design"), json!({"fixed": "in"}), json!({"fixed": {}}), json!(null)] {
|
||||
assert!(units(broken.clone()).is_err(), "{broken}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet's border is saved as a copy of its profile, field by field, and read back as saved, so
|
||||
/// a later built-in never changes it; a profile's name in its place is refused.
|
||||
#[test]
|
||||
fn a_border_is_saved_as_a_copy_of_its_profile() {
|
||||
let v = file(&plate());
|
||||
assert_eq!(v["sheets"][0]["border"], serde_json::to_value(DrawingBorderProfile::iso()).unwrap());
|
||||
assert_eq!(v["sheets"][1]["border"], serde_json::to_value(own_border()).unwrap());
|
||||
assert_eq!(read(&v).unwrap().sheets[1].border, Some(own_border()));
|
||||
let mut edited = v.clone();
|
||||
edited["sheets"][0]["border"]["margins"]["left_mm"] = json!(15.0);
|
||||
let iso = DrawingBorderProfile::iso();
|
||||
let wider = DrawingBorderProfile { margins: Margins { left_mm: 15.0, ..iso.margins }, ..iso };
|
||||
assert_eq!(read(&edited).unwrap().sheets[0].border, Some(wider));
|
||||
edited["sheets"][0]["border"] = json!("iso");
|
||||
assert!(refusal(&edited).starts_with("damaged drawing: "));
|
||||
edited["sheets"][0]["border"] = Value::Null;
|
||||
assert_eq!(read(&edited).unwrap().sheets[0].border, None, "no drawing border");
|
||||
}
|
||||
|
||||
/// What reading refuses: each damage the rules name, refused rather than patched, and files this
|
||||
/// build can't read.
|
||||
#[test]
|
||||
fn damaged_files_are_refused_and_never_patched() {
|
||||
let d = plate();
|
||||
let plate_id = ids::encode(d.sources[0].id);
|
||||
let [assembly_id, bolt_sheet_id] = [0, 1].map(|i| ids::encode(d.sheets[i].id));
|
||||
// Two objects with one id, of one kind and of two kinds.
|
||||
assert_eq!(refused(&d, |v| v["sources"][1]["id"] = json!(plate_id)), format!("damaged drawing: two objects have the id {plate_id}"));
|
||||
assert_eq!(refused(&d, |v| v["sheets"][1]["id"] = json!(assembly_id)), format!("damaged drawing: two objects have the id {assembly_id}"));
|
||||
assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(bolt_sheet_id)), format!("damaged drawing: two objects have the id {bolt_sheet_id}"));
|
||||
// An id that isn't one, where an object has it and where a reference names it; no number form,
|
||||
// and no counter.
|
||||
let not_an_id = "damaged drawing: an id is 13 characters of lowercase Crockford base32, not all 0";
|
||||
for text in ["0000000000000", "FZZZZZZZZZZZZ", "000000000001", "0000000000001 ", "000000000000o", "g000000000000"] {
|
||||
assert_eq!(refused(&d, |v| v["sources"][2]["id"] = json!(text)), not_an_id, "{text:?}");
|
||||
assert_eq!(refused(&d, |v| v["sheets"][1]["documents"]["source"] = json!(text)), not_an_id, "{text:?}");
|
||||
}
|
||||
assert!(refused(&d, |v| v["sources"][2]["id"] = json!(5)).starts_with("damaged drawing: invalid type"));
|
||||
assert!(refused(&d, |v| v["next_id"] = json!(6)).contains("unknown field `next_id`"));
|
||||
let mut in_memory = d.clone();
|
||||
in_memory.sources[2].id = 0;
|
||||
assert_eq!(in_memory.validate().unwrap_err().to_string(), "an object has the id 0, which is never one");
|
||||
// A reference to nothing, or to an object of another kind; a source a sheet documents removed.
|
||||
for missing in [ids::encode(99), assembly_id.clone()] {
|
||||
let expected = format!("damaged drawing: sheet {bolt_sheet_id}: it documents source {missing}, which the drawing doesn't have");
|
||||
assert_eq!(refused(&d, |v| v["sheets"][1]["documents"]["source"] = json!(missing)), expected);
|
||||
}
|
||||
let without_plate = refused(&d, |v| {
|
||||
v["sources"].as_array_mut().unwrap().remove(0);
|
||||
});
|
||||
assert_eq!(without_plate, format!("damaged drawing: sheet {assembly_id}: it documents source {plate_id}, which the drawing doesn't have"));
|
||||
// The paper, its border and its resolution, through the sheet's own rules.
|
||||
let custom = |sides: Value| refused(&d, |v| v["sheets"][0]["sheet"]["size"] = json!({"custom": {"sides_mm": sides}}));
|
||||
assert_eq!(custom(json!([5000.0, 0.5])), "read");
|
||||
for sides in [json!([5000.1, 100.0]), json!([0.0, 100.0]), json!([-1.0, 100.0])] {
|
||||
let expected = format!("damaged drawing: sheet {assembly_id}: drawing sheet: a custom side is finite, above 0 and at most 5000 mm");
|
||||
assert_eq!(custom(sides), expected);
|
||||
}
|
||||
let why = custom(json!([1e10, 100.0]));
|
||||
assert!(why.starts_with("damaged drawing: the number ") && why.ends_with(" is out of range"), "checked before the record: {why}");
|
||||
let nan = d.to_json().unwrap().replacen("600.0", "NaN", 1);
|
||||
assert!(Drawing::from_json(&nan).unwrap_err().to_string().starts_with("not a SolidCraft drawing: "), "NaN isn't JSON");
|
||||
assert_eq!(
|
||||
refused(&d, |v| v["sheets"][1]["border"]["margins"]["top_mm"] = json!(-1.0)),
|
||||
format!("damaged drawing: sheet {bolt_sheet_id}: drawing sheet: a margin is finite and 0 to 5000 mm")
|
||||
);
|
||||
for dpi in [25.0, 2400.0] {
|
||||
assert_eq!(refused(&d, |v| v["sheets"][1]["dpi"] = json!(dpi)), "read", "{dpi}");
|
||||
}
|
||||
for dpi in [24.9, 2400.1, 0.0, -300.0] {
|
||||
let expected = format!("damaged drawing: sheet {bolt_sheet_id}: its pictures are 25 to 2400 dpi");
|
||||
assert_eq!(refused(&d, |v| v["sheets"][1]["dpi"] = json!(dpi)), expected, "{dpi}");
|
||||
}
|
||||
// In memory, where NaN can be, the same rules.
|
||||
let mut in_memory = plate();
|
||||
Arc::make_mut(&mut in_memory.sheets[1]).dpi = f64::NAN;
|
||||
assert_eq!(in_memory.validate().unwrap_err().to_string(), format!("sheet {bolt_sheet_id}: its pictures are 25 to 2400 dpi"));
|
||||
let sheet = Arc::make_mut(&mut in_memory.sheets[1]);
|
||||
sheet.dpi = DEFAULT_DPI;
|
||||
sheet.sheet.size = SheetSize::Custom { sides_mm: [f64::NAN, 100.0] };
|
||||
assert!(in_memory.validate().unwrap_err().to_string().starts_with(&format!("sheet {bolt_sheet_id}: drawing sheet: a custom side")));
|
||||
// An unknown field anywhere.
|
||||
let unknown: [fn(&mut Value); 8] = [
|
||||
|v| v["views"] = json!([]),
|
||||
|v| v["sources"][0]["name"] = json!("Plate"),
|
||||
|v| v["sheets"][0]["scale"] = json!(1),
|
||||
|v| v["sheets"][0]["sheet"]["colour"] = json!("white"),
|
||||
|v| v["sheets"][0]["border"]["style"] = json!("iso"),
|
||||
|v| v["sheets"][0]["documents"]["view"] = json!(1),
|
||||
|v| v["sheets"][0]["excluded"]["export"] = json!(true),
|
||||
|v| v["units"]["fixed"]["precision"] = json!(2),
|
||||
];
|
||||
for (i, edit) in unknown.into_iter().enumerate() {
|
||||
let why = refused(&d, edit);
|
||||
assert!(why.starts_with("damaged drawing: ") && why.contains("unknown field"), "{i}: {why}");
|
||||
}
|
||||
// A newer build's file is refused as one before anything in it is read: a field this build
|
||||
// doesn't know, or a number out of its range.
|
||||
let (current, newer) = (DRAWING.current(), DRAWING.current() + 1);
|
||||
let why = refused(&d, |v| {
|
||||
v["format"] = json!(DRAWING.format_string(newer));
|
||||
v["views"] = json!([]);
|
||||
v["sheets"][0]["dpi"] = json!(1e10);
|
||||
});
|
||||
assert_eq!(
|
||||
why,
|
||||
format!(
|
||||
"this drawing was saved by a newer SolidCraft (file format {newer}; this version reads formats up to {current}): update SolidCraft to open it"
|
||||
)
|
||||
);
|
||||
// A list of pending changes means nothing beside a format made of none; beside the pending
|
||||
// number, a change this build doesn't know is a newer build's.
|
||||
let why = refused(&d, |v| v["format_pending"] = json!(["a-later-change"]));
|
||||
if DRAWING.pending_names().is_empty() {
|
||||
assert_eq!(why, "read");
|
||||
} else {
|
||||
assert!(why.contains("newer SolidCraft"), "{why}");
|
||||
}
|
||||
// A design is never read as a drawing, nor a drawing as a design.
|
||||
let design = crate::Document::new("Plate").to_json();
|
||||
let design_format = crate::format::format_string(crate::format::FORMAT);
|
||||
assert_eq!(Drawing::from_json(&design).unwrap_err().to_string(), format!("not a SolidCraft drawing (format `{design_format}`)"));
|
||||
let drawing_format = DRAWING.format_string(current);
|
||||
assert_eq!(
|
||||
crate::Document::from_json(&d.to_json().unwrap()).unwrap_err().to_string(),
|
||||
format!("not a SolidCraft design (format `{drawing_format}`)")
|
||||
);
|
||||
// Not an object, not JSON, nested too deeply, too large.
|
||||
assert_eq!(Drawing::from_json("[]").unwrap_err().to_string(), "not a SolidCraft drawing (expected a JSON object)");
|
||||
assert!(Drawing::from_json("").unwrap_err().to_string().starts_with("not a SolidCraft drawing: "));
|
||||
let deep: Value = serde_json::from_str(&format!("{}1{}", "[".repeat(100), "]".repeat(100))).unwrap();
|
||||
assert_eq!(refused(&d, |v| v["views"] = deep), "damaged drawing: nested too deeply");
|
||||
let deeper = format!("{{\"format\": \"{drawing_format}\", \"views\": {}1{}}}", "[".repeat(200), "]".repeat(200));
|
||||
assert!(Drawing::from_json(&deeper).unwrap_err().to_string().starts_with("not a SolidCraft drawing: "));
|
||||
let text = d.to_json().unwrap();
|
||||
let at_cap = text.clone() + &" ".repeat(MAX_DRAWING_BYTES - text.len());
|
||||
assert_eq!(Drawing::from_json(&at_cap).unwrap(), d);
|
||||
assert_eq!(Drawing::from_json(&(at_cap + " ")).unwrap_err().to_string(), "drawing too large (at most 16 MiB)");
|
||||
}
|
||||
|
||||
/// A run never gives an id twice: not one in use, and not a removed object's. Only another run from
|
||||
/// the same seed, which tests and replays start from the same file, can give a removed id again.
|
||||
#[test]
|
||||
fn a_run_never_gives_an_id_twice() {
|
||||
let mut id_source = IdSource::seeded(SEED);
|
||||
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Ids", "en", "Sheet", a4, None, &mut id_source).unwrap();
|
||||
let mut given = BTreeSet::from([d.sheets[0].id]);
|
||||
for _ in 0..MAX_SOURCES {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
d.sources.push(Source { id, path: "part.sc".into() });
|
||||
assert!(given.insert(id), "{id:#x} given twice");
|
||||
}
|
||||
let removed = d.sources.pop().unwrap().id;
|
||||
for _ in 0..10_000 {
|
||||
let id = d.allocate_id(&mut id_source).unwrap();
|
||||
assert!(id != removed && given.insert(id), "{id:#x} given twice");
|
||||
}
|
||||
// The same seed, from the start, over a file it made: every draw up to the removed id is in use.
|
||||
let reopened = Drawing::from_json(&d.to_json().unwrap()).unwrap();
|
||||
let again = reopened.allocate_id(&mut IdSource::seeded(SEED)).unwrap();
|
||||
assert!(!reopened.ids().any(|id| id == again));
|
||||
assert_eq!(again, removed);
|
||||
}
|
||||
|
||||
/// Properties follow the design metadata's rules: other tools' data and a well-known key of another
|
||||
/// kind are kept as they are, never read as numbers, while the same number elsewhere is refused.
|
||||
#[test]
|
||||
fn properties_are_kept_as_they_are() {
|
||||
let mut v = file(&plate());
|
||||
v["properties"]["com.example.pdm"] = json!({"huge": 1e300, "ids": [1, 2, 3]});
|
||||
v["properties"]["revision"] = json!(3);
|
||||
let d = read(&v).unwrap();
|
||||
assert_eq!(d.properties.get("com.example.pdm"), Some(&json!({"huge": 1e300, "ids": [1, 2, 3]})));
|
||||
assert_eq!((d.properties.revision(), d.properties.get("revision")), (None, Some(&json!(3))));
|
||||
assert_eq!(read(&file(&d)).unwrap(), d);
|
||||
v["sheets"][0]["dpi"] = json!(1e300);
|
||||
let why = refusal(&v);
|
||||
assert!(why.starts_with("damaged drawing: the number ") && why.ends_with(" is out of range"), "{why}");
|
||||
}
|
||||
|
||||
/// A drawing whose JSON would be over the read limit isn't saved. Other tools' properties are kept
|
||||
/// as they came, and indenting them can grow a file read in under the limit past it, so the save
|
||||
/// refuses rather than write a file that won't open: every drawing saved opens again.
|
||||
#[test]
|
||||
fn a_drawing_too_large_to_reopen_is_not_saved() {
|
||||
let file = |zeros: usize| {
|
||||
let items = vec!["0"; zeros].join(",");
|
||||
let sheet = r#"{"id":"0000000000001","name":"S","sheet":{"size":{"standard":"a4"}}}"#;
|
||||
let format = DRAWING.format_string(1);
|
||||
format!(
|
||||
r#"{{"format":"{format}","name":"A","sources":[],"language":"en","sheets":[{sheet}],"properties":{{"com.example":{{"items":[{items}]}}}}}}"#
|
||||
)
|
||||
};
|
||||
// The case found in review (#3891): 2,000,000 zeros read in at about 4 MB and indent past 16 MiB.
|
||||
let big = Drawing::from_json(&file(2_000_000)).unwrap();
|
||||
assert_eq!(big.to_json().unwrap_err().to_string(), "drawing too large to save (at most 16 MiB)");
|
||||
let small = Drawing::from_json(&file(100_000)).unwrap();
|
||||
assert_eq!(Drawing::from_json(&small.to_json().unwrap()).unwrap(), small);
|
||||
}
|
||||
|
||||
/// A new drawing has one sheet, its id the source's first, follows its designs' units and renders
|
||||
/// pictures at 300 dpi; a name, language or border that breaks the rules is refused.
|
||||
#[test]
|
||||
fn a_new_drawing_has_one_sheet_and_keeps_the_rules() {
|
||||
let d = bare();
|
||||
let first = IdSource::seeded(SEED).next(&BTreeSet::new()).unwrap();
|
||||
assert_eq!((d.sheets.len(), d.sheets[0].id, d.sheets[0].dpi, &d.units), (1, first, DEFAULT_DPI, &Units::FollowModel));
|
||||
assert_eq!((d.format, d.format_pending), (DRAWING.format_string(DRAWING.current()), DRAWING.pending_names()));
|
||||
let a4 = drawing_sheet::Sheet::standard(PaperSize::A4, Orientation::Landscape);
|
||||
let thin = DrawingBorderProfile { drawing_border_line_mm: 0.0, ..DrawingBorderProfile::iso() };
|
||||
let broken = [("", "en", "Sheet", None), ("Bare", "en_GB", "Sheet", None), ("Bare", "en", "\n", None), ("Bare", "en", "Sheet", Some(thin))];
|
||||
for (name, language, sheet, border) in broken {
|
||||
let made = Drawing::new(name, language, sheet, a4, border, &mut IdSource::seeded(SEED));
|
||||
assert!(made.is_err(), "{name:?} {language:?} {sheet:?} {border:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile input never panics: a valid file's text cut, overwritten and given lies, and its JSON
|
||||
/// values replaced, repeated and dropped. Each read gives an error, or a drawing that keeps the
|
||||
/// rules and reads back as it saves.
|
||||
#[test]
|
||||
fn mutated_files_give_an_error_or_a_valid_drawing_never_a_panic() {
|
||||
let good = plate().to_json().unwrap();
|
||||
let tree: Value = serde_json::from_str(&good).unwrap();
|
||||
let nodes = count(&tree) as u64;
|
||||
let lies = [
|
||||
json!(null),
|
||||
json!(0),
|
||||
json!(-1),
|
||||
json!(1),
|
||||
json!(1e10),
|
||||
json!(u64::MAX),
|
||||
json!(""),
|
||||
json!("x".repeat(300)),
|
||||
json!("0000000000000"),
|
||||
json!("0000000000001"),
|
||||
json!("fzzzzzzzzzzzz"),
|
||||
json!("FZZZZZZZZZZZZ"),
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!(true),
|
||||
json!("follow_model"),
|
||||
json!({"fixed": {"unit": "deg"}}),
|
||||
];
|
||||
let words = ["NaN", "1e400", "-0", "18446744073709551616", "\"", "{", "]", ",", "null", "\u{0}", "é", "o", "I"];
|
||||
let mut seed: u64 = 0x9e37_79b9_7f4a_7c15;
|
||||
let mut rnd = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
let mut read_back = 0;
|
||||
for i in 0..10_000 {
|
||||
let text = if i % 2 == 0 {
|
||||
let mut v = tree.clone();
|
||||
let lie = &lies[(rnd() % lies.len() as u64) as usize];
|
||||
mutate(&mut v, &mut ((rnd() % nodes) as usize), lie, rnd() % 3);
|
||||
v.to_string()
|
||||
} else {
|
||||
let mut s = good.clone().into_bytes();
|
||||
for _ in 0..1 + rnd() % 3 {
|
||||
// A run of up to 15 bytes taken out, overwritten by one byte or replaced by a lie.
|
||||
let at = (rnd() % (s.len() as u64 + 1)) as usize;
|
||||
let cut = ((rnd() % 16) as usize).min(s.len() - at);
|
||||
let byte = [(rnd() >> 56) as u8];
|
||||
let put: &[u8] = match rnd() % 3 {
|
||||
0 => &[],
|
||||
1 => &byte,
|
||||
_ => words[(rnd() % words.len() as u64) as usize].as_bytes(),
|
||||
};
|
||||
let next = [&s[..at], put, &s[at + cut..]].concat();
|
||||
s = next;
|
||||
if rnd() % 8 == 0 {
|
||||
s.truncate(at);
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&s).into_owned()
|
||||
};
|
||||
if let Ok(d) = Drawing::from_json(&text) {
|
||||
assert!(d.validate().is_ok() && d.ids().all(|id| id != 0), "{text}");
|
||||
assert_eq!(Drawing::from_json(&d.to_json().unwrap()).unwrap(), d, "{text}");
|
||||
read_back += 1;
|
||||
}
|
||||
}
|
||||
assert!(read_back > 50, "only {read_back} mutated files read: the mutations hardly reach the record's rules");
|
||||
}
|
||||
|
||||
/// The number of values in `v`, itself included.
|
||||
fn count(v: &Value) -> usize {
|
||||
1 + match v {
|
||||
Value::Array(a) => a.iter().map(count).sum::<usize>(),
|
||||
Value::Object(o) => o.values().map(count).sum(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the `pick`th value of `v`, depth first: `how` 1 repeats a list's first element (two
|
||||
/// objects with one id), 2 drops an object's first field, and otherwise `lie` takes its place.
|
||||
fn mutate(v: &mut Value, pick: &mut usize, lie: &Value, how: u64) -> bool {
|
||||
if *pick == 0 {
|
||||
match (v, how) {
|
||||
(Value::Array(a), 1) if !a.is_empty() => {
|
||||
let first = a[0].clone();
|
||||
a.push(first);
|
||||
}
|
||||
(Value::Object(o), 2) if !o.is_empty() => {
|
||||
let key = o.keys().next().cloned().unwrap_or_default();
|
||||
o.remove(&key);
|
||||
}
|
||||
(v, _) => *v = lie.clone(),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
*pick -= 1;
|
||||
match v {
|
||||
Value::Array(a) => a.iter_mut().any(|x| mutate(x, pick, lie, how)),
|
||||
Value::Object(o) => o.values_mut().any(|x| mutate(x, pick, lie, how)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -485,7 +485,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()
|
||||
|
||||
+350
-77
@@ -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])],
|
||||
@@ -300,26 +394,41 @@ 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. Recognize changes across the entire history:
|
||||
/// a branch's pending change may have landed at a later number. Unknown changes are refused
|
||||
/// even after folding, and already-applied changes never run again at their later number.
|
||||
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 change `applied` (its `format_pending`) names that this build doesn't have anywhere in
|
||||
/// its history, which only a newer build's file can. Changes are recognized across the entire
|
||||
/// history: a branch's pending change may have landed at a later number. 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).
|
||||
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 applied = applied.filter(|_| self.parts(version).is_some()).unwrap_or_default();
|
||||
match applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) {
|
||||
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, before anything is changed; already-applied changes never run again at
|
||||
/// their later number.
|
||||
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.
|
||||
let applied = applied.filter(|_| self.parts(version).is_some());
|
||||
let already_applied =
|
||||
|part: &Part| applied.is_some_and(|names| names.iter().any(|name| self.canonical_applied_name(version, name) == part.name));
|
||||
if let Some(applied) = applied {
|
||||
if let Some(unknown) = applied.iter().find(|name| !self.knows_part_name(self.canonical_applied_name(version, name))) {
|
||||
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"
|
||||
)));
|
||||
}
|
||||
if applied.is_some() {
|
||||
for part in self.parts(version).unwrap_or_default().iter().filter(|part| !already_applied(part)) {
|
||||
part.step.map_or(Ok(()), |step| step(v))?;
|
||||
}
|
||||
@@ -338,25 +447,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`.
|
||||
@@ -566,23 +676,7 @@ 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"
|
||||
)));
|
||||
}
|
||||
let (version, applied) = DESIGN.take_version(&mut v)?;
|
||||
check_design_numbers(&v)?;
|
||||
crate::config::validate_rules_json(&v)?;
|
||||
crate::config::validate_styles_json(&v)?;
|
||||
@@ -594,7 +688,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}")))?;
|
||||
check_typed_numbers(&d)?;
|
||||
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
|
||||
@@ -1060,22 +1154,22 @@ const MAX_REAL: f64 = 1e9;
|
||||
/// already bounds its nesting. Engineering SI constants have their own bounded admission
|
||||
/// (e.g. E in Pa): only the exact root user-material card field bypasses the coordinate bound.
|
||||
fn check_design_numbers(v: &Value) -> Result<()> {
|
||||
let Some(root) = v.as_object() else { return check_numbers(v, 0) };
|
||||
let Some(root) = v.as_object() else { return check_numbers(v, "design", 0) };
|
||||
for (key, value) in root {
|
||||
if key == "metadata" {
|
||||
continue;
|
||||
}
|
||||
if key != "user_materials" {
|
||||
check_numbers(value, 1)?;
|
||||
check_numbers(value, "design", 1)?;
|
||||
continue;
|
||||
}
|
||||
let Some(materials) = value.as_array() else { return check_numbers(value, 1) };
|
||||
let Some(materials) = value.as_array() else { return check_numbers(value, "design", 1) };
|
||||
if materials.len() > crate::MAX_USER_MATERIALS {
|
||||
return Err(DocError::Invalid("document exceeds 1000 user materials".into()));
|
||||
}
|
||||
for material in materials {
|
||||
let Some(fields) = material.as_object() else {
|
||||
check_numbers(material, 2)?;
|
||||
check_numbers(material, "design", 2)?;
|
||||
continue;
|
||||
};
|
||||
for (field, data) in fields {
|
||||
@@ -1084,7 +1178,7 @@ fn check_design_numbers(v: &Value) -> Result<()> {
|
||||
if field == "physical_card" && !data.is_null() {
|
||||
crate::physical_material::validate_card(data)?;
|
||||
} else {
|
||||
check_numbers(data, 3)?;
|
||||
check_numbers(data, "design", 3)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1102,21 +1196,22 @@ fn check_typed_numbers(d: &Document) -> Result<()> {
|
||||
check_design_numbers(&typed)
|
||||
}
|
||||
|
||||
/// Every non-integer number outside separately validated engineering cards 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, outside separately validated engineering cards,
|
||||
/// 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(())
|
||||
@@ -1224,25 +1319,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"]);
|
||||
}
|
||||
|
||||
@@ -1257,13 +1352,13 @@ 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());
|
||||
}
|
||||
|
||||
@@ -1276,7 +1371,7 @@ mod tests {
|
||||
let folded = Formats { hand: &hand, folded: &[(29, &first), (30, &cards)], pending: &[] };
|
||||
for formats in [pending, folded] {
|
||||
let mut file = serde_json::json!({"trace": ["b"]});
|
||||
formats.upgrade(&mut file, 29, Some(&["physical-material-cards".into()])).unwrap();
|
||||
formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap();
|
||||
assert_eq!(trace_of(&file), ["b", "a"], "never replay existing card semantics");
|
||||
}
|
||||
}
|
||||
@@ -1288,7 +1383,7 @@ mod tests {
|
||||
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &[] };
|
||||
let mut file = serde_json::json!({"trace": [], "sentinel": 7});
|
||||
let original = file.clone();
|
||||
let error = formats.upgrade(&mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err();
|
||||
let error = formats.upgrade("design", &mut file, 29, Some(&["physical-material-cards".into()])).unwrap_err();
|
||||
assert!(error.to_string().contains("physical-material-cards"), "{error}");
|
||||
assert_eq!(file, original);
|
||||
}
|
||||
@@ -1303,7 +1398,7 @@ mod tests {
|
||||
for version in [28, 30] {
|
||||
let mut file = serde_json::json!({"trace": []});
|
||||
let original = file.clone();
|
||||
let error = formats.upgrade(&mut file, version, Some(&["physical-material-cards".into()])).unwrap_err();
|
||||
let error = formats.upgrade("design", &mut file, version, Some(&["physical-material-cards".into()])).unwrap_err();
|
||||
assert!(error.to_string().contains("physical-material-cards"), "{error}");
|
||||
assert_eq!(file, original);
|
||||
}
|
||||
@@ -1320,7 +1415,7 @@ mod tests {
|
||||
{
|
||||
let mut file = serde_json::json!({"trace": ["b"]});
|
||||
let original = file.clone();
|
||||
let error = formats.upgrade(&mut file, 29, Some(&names)).unwrap_err();
|
||||
let error = formats.upgrade("design", &mut file, 29, Some(&names)).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown-card-codec"), "{error}");
|
||||
assert_eq!(file, original);
|
||||
}
|
||||
@@ -1337,7 +1432,7 @@ mod tests {
|
||||
vec!["material-engineering-cards".into(), "physical-material-cards".into()],
|
||||
] {
|
||||
let mut file = serde_json::json!({"trace": ["b"]});
|
||||
formats.upgrade(&mut file, 29, Some(&names)).unwrap();
|
||||
formats.upgrade("design", &mut file, 29, Some(&names)).unwrap();
|
||||
assert_eq!(trace_of(&file), ["b", "a"]);
|
||||
}
|
||||
}
|
||||
@@ -1350,7 +1445,7 @@ mod tests {
|
||||
let formats = Formats { hand: &hand, folded: &[(29, &first)], pending: &cards };
|
||||
let mut file = serde_json::json!({"trace": ["a"], "metadata": {"format_pending": ["physical-material-cards"]}});
|
||||
let metadata = file["metadata"].clone();
|
||||
formats.upgrade(&mut file, 29, None).unwrap();
|
||||
formats.upgrade("design", &mut file, 29, None).unwrap();
|
||||
assert_eq!(trace_of(&file), ["a", "b"]);
|
||||
assert_eq!(file["metadata"], metadata);
|
||||
}
|
||||
@@ -1365,7 +1460,7 @@ mod tests {
|
||||
let formats = Formats { hand: &[], folded: &[(2, &parts)], pending };
|
||||
let mut file = serde_json::json!({"trace": []});
|
||||
let original = file.clone();
|
||||
let err = formats.upgrade(&mut file, 2, Some(&["experimental-card".into()])).unwrap_err();
|
||||
let err = formats.upgrade("design", &mut file, 2, Some(&["experimental-card".into()])).unwrap_err();
|
||||
assert!(err.to_string().contains("experimental-card") && err.to_string().contains("update SolidCraft"), "{err}");
|
||||
assert_eq!(file, original, "refuse before applying even a known migration");
|
||||
}
|
||||
@@ -1381,14 +1476,192 @@ mod tests {
|
||||
let folded = Formats { hand: &[], folded: &[(2, &first), (3, &later)], pending: &[] };
|
||||
for formats in [pending, folded] {
|
||||
let mut experimental = serde_json::json!({"trace": ["b"]});
|
||||
formats.upgrade(&mut experimental, 2, Some(&["b-second".into()])).unwrap();
|
||||
formats.upgrade("design", &mut experimental, 2, Some(&["b-second".into()])).unwrap();
|
||||
assert_eq!(trace_of(&experimental), ["b", "a"]);
|
||||
let mut released = serde_json::json!({"trace": ["a"]});
|
||||
formats.upgrade(&mut released, 2, None).unwrap();
|
||||
formats.upgrade("design", &mut released, 2, None).unwrap();
|
||||
assert_eq!(trace_of(&released), ["a", "b"], "ordinary released files still get later migrations");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,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());
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,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;
|
||||
@@ -34,6 +35,7 @@ mod format_offsets;
|
||||
pub mod frame;
|
||||
mod history_names;
|
||||
pub mod hole_reference;
|
||||
pub mod ids;
|
||||
pub use format_offsets::whole_profile_selection_from_value;
|
||||
pub mod joints;
|
||||
mod lineage;
|
||||
@@ -69,8 +71,8 @@ pub mod sheet;
|
||||
pub mod text_params;
|
||||
|
||||
pub use assembly::{
|
||||
IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, map_point_expr, mat_inverse,
|
||||
mat_mul, rigid,
|
||||
BomType, IDENTITY, MAX_PLACEMENT_DEPTH, MAX_PLACEMENTS, Mat, Occurrence, WorldNames, apply_point, apply_vector, is_identity, map_point_expr,
|
||||
mat_inverse, mat_mul, rigid,
|
||||
};
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! each may be set (the design, a component or both), how its value is checked and its label.
|
||||
//! It keeps every other key exactly as it was through open, edit and save. Other tools keep
|
||||
//! their own data under one reverse-DNS key each (`com.example.tool`), holding an object.
|
||||
//! A component's part properties have one more key: its bill of materials type (`bom_type`).
|
||||
//!
|
||||
//! 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
|
||||
@@ -20,7 +21,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";
|
||||
@@ -92,6 +93,11 @@ pub static KEYS: &[Key] = &[
|
||||
/// own, and the Bill of Materials window edits them.
|
||||
pub static BOM_KEYS: &[&str] = &[PART_NUMBER, DESCRIPTION, REVISION];
|
||||
|
||||
/// A component's [`BomType`] in the bill of materials (one of [`BomType::as_str`]'s words). It is
|
||||
/// a part property of a component only, not a row of [`KEYS`], so the Design Properties dialog
|
||||
/// doesn't list it and a design refuses 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.
|
||||
@@ -114,9 +120,10 @@ pub fn keys(owner: Owner) -> impl Iterator<Item = &'static Key> {
|
||||
KEYS.iter().filter(move |k| k.scope.covers(owner))
|
||||
}
|
||||
|
||||
/// The names of [`keys`]`(owner)`, as refusals list them.
|
||||
/// The names of [`keys`]`(owner)`, and a component's [`BOM_TYPE`], as refusals list them.
|
||||
pub fn key_list(owner: Owner) -> String {
|
||||
keys(owner).map(|k| k.name).collect::<Vec<_>>().join(", ")
|
||||
let bom_type = (owner == Owner::Component).then_some(BOM_TYPE);
|
||||
keys(owner).map(|k| k.name).chain(bom_type).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// One of SolidCraft's keys, by name.
|
||||
@@ -126,7 +133,7 @@ pub fn key(name: &str) -> Option<&'static Key> {
|
||||
|
||||
/// A key SolidCraft reads as a design or part property.
|
||||
pub fn is_well_known(name: &str) -> bool {
|
||||
key(name).is_some()
|
||||
key(name).is_some() || name == BOM_TYPE
|
||||
}
|
||||
|
||||
/// A reverse-DNS key another tool keeps its data under: two or more dot-separated labels of
|
||||
@@ -162,6 +169,19 @@ fn text(key: &str, value: Value) -> Result<Option<Value>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bill of materials type's check: one of [`BomType`]'s words, which empty text (like
|
||||
/// `null`) removes, so the component reads as normal again.
|
||||
fn bom_type(value: Value) -> Result<Option<Value>> {
|
||||
let Some(word) = text(BOM_TYPE, value)? else { return Ok(None) };
|
||||
let s = word.as_str().unwrap_or_default();
|
||||
if 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(word))
|
||||
}
|
||||
|
||||
fn depth(v: &Value) -> usize {
|
||||
// Iterative, so a hostile value can't overflow the stack here.
|
||||
let mut deepest = 0;
|
||||
@@ -223,6 +243,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.
|
||||
@@ -260,6 +285,9 @@ impl Metadata {
|
||||
|
||||
/// `value` as `key` may hold it in `owner`'s metadata (`None`: remove the key), or why it can't.
|
||||
fn check(owner: Owner, key: &str, value: Value) -> Result<Option<Value>> {
|
||||
if key == BOM_TYPE && owner == Owner::Component {
|
||||
return bom_type(value);
|
||||
}
|
||||
if let Some(k) = self::key(key).filter(|k| k.scope.covers(owner)) {
|
||||
return (k.check)(key, value);
|
||||
}
|
||||
@@ -333,6 +361,30 @@ 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(Component, 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(Component, BOM_TYPE, bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(m.bom_type(), BomType::Inseparable, "a refused value leaves the old one");
|
||||
assert!(m.set(Component, 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!(!keys(Design).any(|k| k.name == BOM_TYPE), "the Design Properties dialog doesn't list it");
|
||||
assert!(Metadata::default().set(Design, BOM_TYPE, json!("phantom")).is_err(), "a design has no bill of materials type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_limits_refuse_growth_but_let_an_oversized_file_shrink() {
|
||||
let mut m = Metadata::default();
|
||||
@@ -370,7 +422,7 @@ mod tests {
|
||||
}
|
||||
// Every key SolidCraft took on the design and on a component before the table still is.
|
||||
assert_eq!(key_list(Design), "project, part_number, revision, author, organization, description, license");
|
||||
assert_eq!(key_list(Component), key_list(Design));
|
||||
assert_eq!(key_list(Component), format!("{}, {BOM_TYPE}", key_list(Design)), "a component also takes its bill of materials type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -402,7 +454,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
refusal(Component, "colour"),
|
||||
"`colour` is not a part property: SolidCraft's are project, part_number, revision, author, organization, \
|
||||
description, license; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
|
||||
description, license, bom_type; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
|
||||
);
|
||||
for owner in [Design, Component] {
|
||||
let word = owner.names().0;
|
||||
|
||||
+3
-18
@@ -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());
|
||||
@@ -4315,7 +4300,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() };
|
||||
@@ -4676,7 +4661,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()
|
||||
@@ -5325,7 +5310,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
|
||||
|
||||
@@ -5,11 +5,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::{BOM_KEYS, MAX_KEYS, Owner, check_text, key_list};
|
||||
use solidcraft_doc::BomType;
|
||||
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, MAX_KEYS, Owner, check_text, key_list};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
@@ -18,13 +24,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, as document.metadata's properties lists them, 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, as document.metadata's properties lists them, bom_type, or a reverse-DNS key holding an object); \
|
||||
properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, \
|
||||
metadata, properties, changed: [keys]",
|
||||
),
|
||||
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 \
|
||||
@@ -32,8 +39,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)",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -72,6 +81,10 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in BOM_KEYS {
|
||||
out.insert(k.to_string(), json!(m.text(k)));
|
||||
}
|
||||
// A bill of materials type is a component's; the design has none.
|
||||
if id != 0 {
|
||||
out.insert(BOM_TYPE.into(), json!(m.bom_type().as_str()));
|
||||
}
|
||||
out.insert("metadata".into(), json!(m));
|
||||
out.insert("properties".into(), properties);
|
||||
Ok(Value::Object(out))
|
||||
@@ -111,7 +124,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::Object(_)) => return Err(bad(cmd, format!("`values` can hold at most {MAX_KEYS} keys"))),
|
||||
Some(_) => return Err(bad(cmd, "`values` must be an object: {key: value, …}")),
|
||||
}
|
||||
for k in BOM_KEYS {
|
||||
for k in BOM_KEYS.iter().chain([&BOM_TYPE]) {
|
||||
if let Some(v) = p.get(*k) {
|
||||
entries.push((k.to_string(), v.clone()));
|
||||
}
|
||||
@@ -123,7 +136,7 @@ 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 {}, values or properties ({})", BOM_KEYS.join(", "), key_list(Owner::Component))));
|
||||
return Err(bad(cmd, format!("give {}, {BOM_TYPE}, values or properties ({})", BOM_KEYS.join(", "), key_list(Owner::Component))));
|
||||
}
|
||||
let mut changed: Vec<String> = Vec::new();
|
||||
for (k, v) in entries {
|
||||
@@ -176,6 +189,7 @@ struct Row {
|
||||
part_number: String,
|
||||
description: String,
|
||||
revision: String,
|
||||
bom_type: BomType,
|
||||
count: u64,
|
||||
components: Vec<u64>,
|
||||
names: Vec<String>,
|
||||
@@ -225,6 +239,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 {
|
||||
@@ -234,8 +250,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> {
|
||||
@@ -246,63 +276,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(),
|
||||
@@ -361,10 +416,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);
|
||||
@@ -372,22 +428,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);
|
||||
|
||||
@@ -267,7 +267,7 @@ fn a_components_refusals_speak_of_part_properties() {
|
||||
assert!(
|
||||
bogus.ends_with(
|
||||
"`bogus` is not a part property: SolidCraft's are project, part_number, revision, author, organization, description, \
|
||||
license; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
|
||||
license, bom_type; another tool's data goes under a reverse-DNS key such as `com.example.tool`"
|
||||
),
|
||||
"{bogus}"
|
||||
);
|
||||
@@ -275,8 +275,8 @@ fn a_components_refusals_speak_of_part_properties() {
|
||||
assert!(refusal(&mut s, json!({"component": "Pin", "values": {"": "x"}})).contains("`` is not a part property"));
|
||||
// Nothing to change: the same list as before the table.
|
||||
assert!(refusal(&mut s, json!({"component": "Pin"})).ends_with(
|
||||
"give part_number, description, revision, values or properties (project, part_number, revision, author, organization, \
|
||||
description, license)"
|
||||
"give part_number, description, revision, bom_type, values or properties (project, part_number, revision, author, \
|
||||
organization, description, license, bom_type)"
|
||||
));
|
||||
// Only the bill of materials' keys are parameters of their own; the others go in `values`.
|
||||
assert!(refusal(&mut s, json!({"component": "Pin", "author": "Jane"})).contains("give part_number, description, revision"));
|
||||
@@ -400,3 +400,227 @@ fn body_owners_match_each_bodys_component() {
|
||||
assert_eq!(s.doc.model_body_components(&st), in_order);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)]);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! nothing: properties are no geometry.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_doc::metadata::{self, MAX_KEYS, Owner};
|
||||
use solidcraft_doc::metadata::{self, BOM_TYPE, MAX_KEYS, Owner};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
@@ -49,6 +49,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut next = s.doc.metadata.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(Owner::Design, key, value.clone()).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
changed.push(key.to_string());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -847,3 +847,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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
+49
-23
@@ -5,8 +5,10 @@ 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 drawing::{FileKind, file_kind, read_drawing, read_drawing_file, write_drawing_container};
|
||||
pub use native_cache::read_design_geometry;
|
||||
pub use thumbnail::{THUMBNAIL_SIZE, read_design_thumbnail, read_design_thumbnail_file};
|
||||
|
||||
@@ -88,6 +90,12 @@ fn part_limit(name: &str) -> usize {
|
||||
}
|
||||
|
||||
impl PackageWriter {
|
||||
fn new() -> PackageWriter {
|
||||
PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 }
|
||||
}
|
||||
fn finish(self) -> Result<Vec<u8>> {
|
||||
Ok(self.zip.finish().map_err(invalid)?.into_inner())
|
||||
}
|
||||
fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<()> {
|
||||
if bytes.len() > part_limit(name) {
|
||||
return Err(invalid("part too large"));
|
||||
@@ -245,7 +253,7 @@ fn write_container(
|
||||
budget: usize,
|
||||
) -> Result<Vec<u8>> {
|
||||
doc.render.validate().map_err(invalid)?;
|
||||
let mut writer = PackageWriter { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), names: BTreeSet::new(), expanded: 0, resolved: 0 };
|
||||
let mut writer = PackageWriter::new();
|
||||
let mut features = Vec::with_capacity(doc.features.len());
|
||||
for feature in &doc.features {
|
||||
let replacements = match &feature.kind {
|
||||
@@ -326,7 +334,7 @@ fn write_container(
|
||||
if let Some(png) = thumbnail {
|
||||
thumbnail::write(&mut writer, png, &metadata)?;
|
||||
}
|
||||
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
|
||||
let bytes = writer.finish()?;
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(invalid(format!("the design would be {} MB, over the {} MB a design file may hold", bytes.len() >> 20, MAX_DESIGN_BYTES >> 20)));
|
||||
}
|
||||
@@ -463,8 +471,8 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
}
|
||||
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
|
||||
}
|
||||
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
|
||||
self.limited_part(name, part_limit(name))
|
||||
fn has_part(&self, name: &str) -> bool {
|
||||
self.zip.index_for_name(name).is_some()
|
||||
}
|
||||
fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>> {
|
||||
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
|
||||
@@ -654,6 +662,33 @@ pub(super) fn read(bytes: &[u8]) -> Result<Document> {
|
||||
/// keeps one seekable file handle instead of buffering the complete archive. JSON continues
|
||||
/// through the normal migrations.
|
||||
pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>) -> Result<(Document, Option<solidcraft_doc::SavedModel>)> {
|
||||
let mut source = open_regular_file(path)?;
|
||||
if is_container_source(&mut source)? {
|
||||
let mut reader = DesignContainerReader::new(source)?;
|
||||
// A design with its model opens without a picture first: the model is the session.
|
||||
let model = reader.geometry();
|
||||
if let Some(notify) = notify
|
||||
&& model.is_none()
|
||||
&& let Ok(preview) = read_preview(&mut reader.reader)
|
||||
{
|
||||
notify(preview);
|
||||
}
|
||||
Ok((reader.read_document()?, model))
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
Ok((crate::read_design(&bytes)?, None))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type FileSource = std::fs::File;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
type FileSource = Cursor<Vec<u8>>;
|
||||
|
||||
/// A file to read: inside the folders this process may use, a regular file, and at most
|
||||
/// [`MAX_DESIGN_BYTES`], read from its start.
|
||||
fn open_regular_file(path: &str) -> Result<FileSource> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mut source = {
|
||||
crate::vfs::check(path).map_err(invalid)?;
|
||||
@@ -677,33 +712,24 @@ pub fn read_design_file(path: &str, notify: Option<impl FnOnce(DesignPreview)>)
|
||||
return Err(invalid("file too large"));
|
||||
}
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
/// Whether a source starts as a container does, leaving it at its start.
|
||||
fn is_container_source(source: &mut FileSource) -> Result<bool> {
|
||||
let mut prefix = [0u8; 4];
|
||||
let size = source.seek(SeekFrom::End(0)).map_err(invalid)?;
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
if size >= 4 {
|
||||
source.read_exact(&mut prefix).map_err(invalid)?;
|
||||
}
|
||||
source.seek(SeekFrom::Start(0)).map_err(invalid)?;
|
||||
if is_design_container(&prefix) {
|
||||
let mut reader = DesignContainerReader::new(source)?;
|
||||
// A design with its model opens without a picture first: the model is the session.
|
||||
let model = reader.geometry();
|
||||
if let Some(notify) = notify
|
||||
&& model.is_none()
|
||||
&& let Ok(preview) = read_preview(&mut reader.reader)
|
||||
{
|
||||
notify(preview);
|
||||
}
|
||||
Ok((reader.read_document()?, model))
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
source.take(MAX_DESIGN_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
Ok((crate::read_design(&bytes)?, None))
|
||||
}
|
||||
Ok(is_design_container(&prefix))
|
||||
}
|
||||
|
||||
fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Document> {
|
||||
let version: Value = serde_json::from_slice(&reader.part("container.json")?).map_err(invalid)?;
|
||||
if !matches!(version.get("format").and_then(Value::as_str), Some(VERSION | LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
if drawing::container_kind(reader)? == FileKind::Drawing {
|
||||
return Err(IoError::Invalid("this file is a SolidCraft drawing, not a design: open it as a drawing".into()));
|
||||
}
|
||||
let bytes = reader.document(MAX_DESIGN_BYTES)?;
|
||||
check_hdr_manifest(&bytes)?;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! Drawing files (#3890): a [`Drawing`] saved as a container or as plain JSON, like a design.
|
||||
//!
|
||||
//! A drawing container is a design container holding `drawing.json` (Deflate, as
|
||||
//! `document.json`) in place of `document.json`, and saying so in `container.json`:
|
||||
//! `{"content": "drawing", "format": "solidcraft-container/1"}`. A design's `container.json` has no
|
||||
//! `content`, as before. The container's own checks (budgets, entries, the ZIP directory) are the
|
||||
//! design container's. The plain JSON form is [`Drawing::to_json`]'s text, the same bytes each
|
||||
//! save, so a drawing can live in version control as text.
|
||||
//!
|
||||
//! A design is never read as a drawing nor a drawing as a design: [`file_kind`] tells them apart,
|
||||
//! and each reader refuses the other's file by name.
|
||||
|
||||
use std::io::{Cursor, Read, Seek};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::drawing::{Drawing, MAX_DRAWING_BYTES};
|
||||
use solidcraft_doc::format::DRAWING;
|
||||
|
||||
use super::{PackageReader, PackageWriter, VERSION, invalid, is_design_container};
|
||||
use crate::{IoError, MAX_DESIGN_BYTES, Result};
|
||||
|
||||
/// The part a drawing container keeps its drawing in.
|
||||
const DRAWING_PART: &str = "drawing.json";
|
||||
/// What a drawing's `container.json` says it holds.
|
||||
const DRAWING_CONTENT: &str = "drawing";
|
||||
|
||||
/// What a SolidCraft file holds.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
Design,
|
||||
Drawing,
|
||||
}
|
||||
|
||||
/// Which kind of file `bytes` are, from the container's `container.json` or the JSON's `format`.
|
||||
/// JSON that isn't a drawing's counts as a design, which the design reader then checks.
|
||||
pub fn file_kind(bytes: &[u8]) -> Result<FileKind> {
|
||||
if is_design_container(bytes) {
|
||||
return container_kind(&mut PackageReader::new(Cursor::new(bytes))?);
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Format {
|
||||
format: Option<String>,
|
||||
}
|
||||
let format = serde_json::from_slice::<Format>(bytes).ok().and_then(|f| f.format);
|
||||
Ok(if format.is_some_and(|f| DRAWING.version_of(&f).is_some()) { FileKind::Drawing } else { FileKind::Design })
|
||||
}
|
||||
|
||||
/// What a container holds, by its `container.json`: no `content` is a design.
|
||||
pub(super) fn container_kind<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<FileKind> {
|
||||
let container: Value = serde_json::from_slice(&reader.limited_part("container.json", 4096)?).map_err(invalid)?;
|
||||
if !matches!(container.get("format").and_then(Value::as_str), Some(super::VERSION | super::LEGACY_VERSION)) {
|
||||
return Err(invalid("unsupported container version"));
|
||||
}
|
||||
match container.get("content") {
|
||||
None => Ok(FileKind::Design),
|
||||
Some(Value::String(content)) if content == DRAWING_CONTENT => Ok(FileKind::Drawing),
|
||||
Some(other) => Err(invalid(format!("this build doesn't know container content {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The drawing as a container: the same bytes for the same drawing.
|
||||
pub fn write_drawing_container(drawing: &Drawing) -> Result<Vec<u8>> {
|
||||
let text = drawing.to_json()?;
|
||||
let mut writer = PackageWriter::new();
|
||||
writer.part(DRAWING_PART, text.as_bytes(), true)?;
|
||||
let container = json!({"format": VERSION, "content": DRAWING_CONTENT});
|
||||
writer.part("container.json", &serde_json::to_vec(&container).map_err(invalid)?, false)?;
|
||||
writer.finish()
|
||||
}
|
||||
|
||||
/// Read a drawing file's bytes: a container or plain JSON.
|
||||
pub fn read_drawing(bytes: &[u8]) -> Result<Drawing> {
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(IoError::Invalid("drawing file too large".into()));
|
||||
}
|
||||
if is_design_container(bytes) {
|
||||
return read_container(Cursor::new(bytes));
|
||||
}
|
||||
if file_kind(bytes)? == FileKind::Design {
|
||||
return Err(IoError::Invalid("this file is a SolidCraft design, not a drawing: open it as a design".into()));
|
||||
}
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("drawing file is not UTF-8 JSON".into()))?;
|
||||
Ok(Drawing::from_json(text)?)
|
||||
}
|
||||
|
||||
/// Read a drawing from disk (or the browser's files), a container through one seekable handle.
|
||||
pub fn read_drawing_file(path: &str) -> Result<Drawing> {
|
||||
let mut source = super::open_regular_file(path)?;
|
||||
if super::is_container_source(&mut source)? {
|
||||
return read_container(source);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
source.take(MAX_DRAWING_BYTES as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
read_drawing(&bytes)
|
||||
}
|
||||
|
||||
fn read_container<R: Read + Seek>(source: R) -> Result<Drawing> {
|
||||
let mut reader = PackageReader::new(source)?;
|
||||
if container_kind(&mut reader)? == FileKind::Design {
|
||||
return Err(IoError::Invalid("this file is a SolidCraft design, not a drawing: open it as a design".into()));
|
||||
}
|
||||
if reader.has_part("document.json") {
|
||||
return Err(invalid("a drawing container also holds a design's document.json"));
|
||||
}
|
||||
let bytes = reader.limited_part(DRAWING_PART, MAX_DRAWING_BYTES)?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(invalid)?;
|
||||
Ok(Drawing::from_json(text)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawing_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,155 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_doc::drawing::Source;
|
||||
use solidcraft_doc::ids::IdSource;
|
||||
use solidcraft_geom::drawing_sheet::{self, DrawingBorderProfile, Orientation, PaperSize};
|
||||
|
||||
use super::*;
|
||||
use crate::{is_drawing_path, read_design, write_design, write_design_container};
|
||||
|
||||
/// A drawing of two designs on one A3 sheet.
|
||||
fn drawing() -> Drawing {
|
||||
let mut ids = IdSource::seeded(3890);
|
||||
let a3 = drawing_sheet::Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut d = Drawing::new("Plate assembly", "en-GB", "Assembly", a3, Some(DrawingBorderProfile::iso()), &mut ids).unwrap();
|
||||
for path in ["plate.sc", "../parts/bolt.sc"] {
|
||||
let id = d.allocate_id(&mut ids).unwrap();
|
||||
d.sources.push(Source { id, path: path.into() });
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// 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 written part by part, as a hostile or older tool might.
|
||||
fn container(parts: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut writer = PackageWriter::new();
|
||||
for (name, bytes) in parts {
|
||||
writer.part(name, bytes, true).unwrap();
|
||||
}
|
||||
writer.finish().unwrap()
|
||||
}
|
||||
|
||||
fn error(result: Result<impl std::fmt::Debug>) -> String {
|
||||
result.err().map(|e| e.to_string()).unwrap_or_else(|| panic!("accepted"))
|
||||
}
|
||||
|
||||
/// A drawing round-trips through both forms, each giving the same bytes every save; the
|
||||
/// container holds `container.json` and `drawing.json`, and no `document.json`.
|
||||
#[test]
|
||||
fn a_drawing_round_trips_through_both_forms_byte_for_byte() {
|
||||
let d = drawing();
|
||||
let bytes = write_drawing_container(&d).unwrap();
|
||||
assert_eq!(read_drawing(&bytes).unwrap(), d);
|
||||
assert_eq!(write_drawing_container(&read_drawing(&bytes).unwrap()).unwrap(), bytes, "save, open, save: the same bytes");
|
||||
let inside = parts(&bytes);
|
||||
assert_eq!(inside.keys().map(String::as_str).collect::<Vec<_>>(), ["container.json", "drawing.json"]);
|
||||
assert_eq!(inside["container.json"], br#"{"content":"drawing","format":"solidcraft-container/1"}"#);
|
||||
let json = d.to_json().unwrap();
|
||||
assert_eq!(inside["drawing.json"], json.as_bytes(), "the container holds the plain form's text");
|
||||
let again = read_drawing(json.as_bytes()).unwrap();
|
||||
assert_eq!((again.to_json().unwrap(), &again), (json, &d));
|
||||
}
|
||||
|
||||
/// Each file is the kind it says: a design container's `container.json` has no `content`, a
|
||||
/// drawing's says `drawing`, and plain JSON is a drawing when its format is one.
|
||||
#[test]
|
||||
fn file_kind_tells_designs_and_drawings_apart() {
|
||||
let doc = Document::new("Plate");
|
||||
let design = write_design_container(&doc).unwrap();
|
||||
assert_eq!(parts(&design)["container.json"], br#"{"format":"solidcraft-container/1"}"#, "a design's container is as it was");
|
||||
let d = drawing();
|
||||
let cases = [
|
||||
(design, FileKind::Design),
|
||||
(write_design(&doc), FileKind::Design),
|
||||
(write_drawing_container(&d).unwrap(), FileKind::Drawing),
|
||||
(d.to_json().unwrap().into_bytes(), FileKind::Drawing),
|
||||
(b"{\"format\": \"solidcraft-drawing/1\"}".to_vec(), FileKind::Drawing),
|
||||
(b"{\"format\": \"solidcraft/1\"}".to_vec(), FileKind::Design),
|
||||
(b"not json".to_vec(), FileKind::Design),
|
||||
];
|
||||
for (bytes, kind) in cases {
|
||||
assert_eq!(file_kind(&bytes).unwrap(), kind, "{}", String::from_utf8_lossy(&bytes[..bytes.len().min(40)]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Neither reader takes the other's file, and each says what the file is.
|
||||
#[test]
|
||||
fn a_design_is_never_read_as_a_drawing_nor_a_drawing_as_a_design() {
|
||||
let d = drawing();
|
||||
for bytes in [write_drawing_container(&d).unwrap(), d.to_json().unwrap().into_bytes()] {
|
||||
assert!(error(read_design(&bytes)).contains("is a SolidCraft drawing, not a design"));
|
||||
}
|
||||
let doc = Document::new("Plate");
|
||||
for bytes in [write_design_container(&doc).unwrap(), write_design(&doc)] {
|
||||
assert!(error(read_drawing(&bytes)).contains("is a SolidCraft design, not a drawing"));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a drawing container refuses: content this build doesn't know, a design's document beside
|
||||
/// the drawing, a missing drawing, an unknown container version, and a damaged drawing.
|
||||
#[test]
|
||||
fn a_drawing_container_refuses_what_it_cant_take() {
|
||||
let text = drawing().to_json().unwrap();
|
||||
let drawing_json = |content: &str| format!(r#"{{"content":{content},"format":"solidcraft-container/1"}}"#);
|
||||
let cases: [(Vec<u8>, &str); 6] = [
|
||||
(container(&[("container.json", drawing_json("\"sketch\"").as_bytes()), ("drawing.json", text.as_bytes())]), "container content \"sketch\""),
|
||||
(container(&[("container.json", drawing_json("7").as_bytes()), ("drawing.json", text.as_bytes())]), "container content 7"),
|
||||
(
|
||||
container(&[
|
||||
("container.json", drawing_json("\"drawing\"").as_bytes()),
|
||||
("drawing.json", text.as_bytes()),
|
||||
("document.json", &b"{}"[..]),
|
||||
]),
|
||||
"also holds a design's document.json",
|
||||
),
|
||||
(container(&[("container.json", drawing_json("\"drawing\"").as_bytes())]), "required part `drawing.json`"),
|
||||
(
|
||||
container(&[("container.json", &br#"{"content":"drawing","format":"solidcraft-container/9"}"#[..]), ("drawing.json", text.as_bytes())]),
|
||||
"version",
|
||||
),
|
||||
(
|
||||
container(&[("container.json", drawing_json("\"drawing\"").as_bytes()), ("drawing.json", text.replace("en-GB", "e").as_bytes())]),
|
||||
"damaged drawing",
|
||||
),
|
||||
];
|
||||
for (bytes, says) in cases {
|
||||
let e = error(read_drawing(&bytes));
|
||||
assert!(e.contains(says), "{says}: {e}");
|
||||
}
|
||||
assert!(error(read_design(&container(&[("container.json", drawing_json("\"sketch\"").as_bytes())]))).contains("container content"));
|
||||
}
|
||||
|
||||
/// Drawing files: the extension, and reading one from disk in either form.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn drawing_files_open_from_disk_in_either_form() {
|
||||
// The browsers' file pickers take only ASCII letters, digits, `+` and `.`, up to 16 long.
|
||||
assert!(crate::DRAWING_EXT.len() < 16 && crate::DRAWING_EXT.bytes().all(|b| b.is_ascii_alphanumeric()), "{}", crate::DRAWING_EXT);
|
||||
for (path, drawing) in [("a.scdwg", true), ("A.SCDWG", true), ("a.sc", false), ("scdwg", false), ("a.dwg", false)] {
|
||||
assert_eq!(is_drawing_path(path), drawing, "{path}");
|
||||
}
|
||||
let d = drawing();
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-drawing-files-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (name, bytes) in [("container.scdwg", write_drawing_container(&d).unwrap()), ("plain.scdwg", d.to_json().unwrap().into_bytes())] {
|
||||
let path = dir.join(name);
|
||||
std::fs::File::create(&path).unwrap().write_all(&bytes).unwrap();
|
||||
assert_eq!(read_drawing_file(path.to_str().unwrap()).unwrap(), d, "{name}");
|
||||
}
|
||||
assert!(error(read_drawing_file(dir.to_str().unwrap())).contains("not a regular file"));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
+18
-4
@@ -14,15 +14,17 @@ mod dxf;
|
||||
mod dxf_out;
|
||||
pub mod formats;
|
||||
pub use design::{
|
||||
DesignCompression, DesignContainerReader, DesignPreview, THUMBNAIL_SIZE, is_design_container, read_design_file, read_design_geometry,
|
||||
read_design_preview, read_design_thumbnail, read_design_thumbnail_file, write_design_container, write_design_container_with_cache,
|
||||
write_design_container_with_compression, write_design_container_with_preview, write_design_container_with_thumbnail,
|
||||
DesignCompression, DesignContainerReader, DesignPreview, FileKind, THUMBNAIL_SIZE, file_kind, is_design_container, read_design_file,
|
||||
read_design_geometry, read_design_preview, read_design_thumbnail, read_design_thumbnail_file, read_drawing, read_drawing_file,
|
||||
write_design_container, write_design_container_with_cache, write_design_container_with_compression, write_design_container_with_preview,
|
||||
write_design_container_with_thumbnail, write_drawing_container,
|
||||
};
|
||||
mod legacy_dir;
|
||||
#[cfg(test)]
|
||||
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;
|
||||
@@ -69,6 +71,14 @@ pub fn is_design_path(path: &str) -> bool {
|
||||
pub fn is_design_container_path(path: &std::path::Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(is_design_ext)
|
||||
}
|
||||
/// Drawing file extension: a drawing is a file of its own beside the designs it draws. Short and
|
||||
/// plain, as the browsers' file pickers take only ASCII letters, digits, `+` and `.`, up to 16 long.
|
||||
pub const DRAWING_EXT: &str = "scdwg";
|
||||
|
||||
/// Is this a drawing file name (`.scdwg`, any case)?
|
||||
pub fn is_drawing_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case(DRAWING_EXT))
|
||||
}
|
||||
/// Largest design file we read.
|
||||
pub const MAX_DESIGN_BYTES: usize = 256 << 20;
|
||||
|
||||
@@ -667,7 +677,11 @@ pub fn read_design(bytes: &[u8]) -> Result<Document> {
|
||||
return design::read(bytes);
|
||||
}
|
||||
let s = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("design file is not UTF-8 JSON".into()))?;
|
||||
Ok(Document::from_json(s)?)
|
||||
// Only a file that isn't a design pays for asking whether it is a drawing.
|
||||
Document::from_json(s).map_err(|e| match file_kind(bytes) {
|
||||
Ok(FileKind::Drawing) => IoError::Invalid("this file is a SolidCraft drawing, not a design: open it as a drawing".into()),
|
||||
_ => e.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Design file bytes.
|
||||
|
||||
@@ -525,6 +525,55 @@ outer
|
||||
survives("design JSON", &mutations(&json, true, 0xde6), usize::MAX, |b| crate::read_design(b).ok().map(|d| d.features.len()));
|
||||
}
|
||||
|
||||
/// A drawing, as a container and as plain JSON: every mutation gives an error or a drawing,
|
||||
/// never a panic, and `file_kind` always answers.
|
||||
#[test]
|
||||
fn drawings_survive_mutation() {
|
||||
use solidcraft_geom::drawing_sheet::{DrawingBorderProfile, Orientation, PaperSize, Sheet};
|
||||
let mut ids = solidcraft_doc::ids::IdSource::seeded(0xd4a);
|
||||
let a3 = Sheet::standard(PaperSize::A3, Orientation::Landscape);
|
||||
let mut drawing = solidcraft_doc::drawing::Drawing::new("Fuzz", "en", "Sheet 1", a3, Some(DrawingBorderProfile::iso()), &mut ids).unwrap();
|
||||
let id = drawing.allocate_id(&mut ids).unwrap();
|
||||
drawing.sources.push(solidcraft_doc::drawing::Source { id, path: "plate.sc".into() });
|
||||
let container = crate::write_drawing_container(&drawing).unwrap();
|
||||
assert!(crate::read_drawing(&container).is_ok());
|
||||
survives("drawing container", &mutations(&container, false, 0xd4b), usize::MAX, |b| {
|
||||
let _ = crate::file_kind(b);
|
||||
let _ = crate::read_design(b);
|
||||
crate::read_drawing(b).ok().map(|d| d.sheets.len())
|
||||
});
|
||||
let json = drawing.to_json().unwrap().into_bytes();
|
||||
assert!(crate::read_drawing(&json).is_ok());
|
||||
survives("drawing JSON", &mutations(&json, true, 0xd4c), usize::MAX, |b| {
|
||||
let _ = crate::file_kind(b);
|
||||
crate::read_drawing(b).ok().map(|d| d.sheets.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+/";
|
||||
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -398,7 +398,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
|
||||
@@ -420,8 +420,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
|
||||
|
||||
@@ -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.
|
||||
@@ -365,6 +402,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` |
|
||||
| Quantity | the number of pattern instances | count | the pattern dialogs' Quantity rows, `ui.quantity` |
|
||||
| | what the motion plot shows: value, velocity or acceleration | plotted quantity | the motion study's Quantity row, `motion.plot_quantity` |
|
||||
| Reference | an edge a hole or cylinder keeps its distance from | *reference edge* | `references` of `solid.hole` and `solid.cylinder`, `ui.reference_1` |
|
||||
|
||||
@@ -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
|
||||
+16
-7
@@ -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. Swept volumes use Pappus; Z moments account for inward-offset perimeter P(u)=P-2pi*u: fillet top-depth moment P*A*d-2pi*r^4*(19/24-pi/4), A=r^2*(1-pi/4), d=r*(10-3pi)/(3*(4-pi)); bottom chamfer Z moment P*c^3/6-pi*c^4/12; surfaces use 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
@@ -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`.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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##*/}"
|
||||
|
||||
@@ -14,6 +14,8 @@ A design says which format it is in (`"format": "solidcraft/N"`). A change that
|
||||
|
||||
Two PRs that each add a change merge without 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 pending files into `v<N>/` for good; `scripts/test-format-pending.sh` runs in CI. Never add a file to a `v<N>/` folder afterwards. See `docs/releasing.md`.
|
||||
|
||||
Drawings follow the same rules with their own numbers, from 1: a drawing-format change is ONE new file in `crates/doc/src/drawing/format/pending/` (see its `README.md`), and the same fold script moves them into `crates/doc/src/drawing/format/v<N>/`.
|
||||
|
||||
## Import
|
||||
- Every import (STEP, IGES, 3MF, STL, OBJ; inserted, dropped, opened, or run as `file.insert_step`, `file.insert_mesh` or `doc.open`) becomes a new component named after the file, with the Import feature inside it. Bodies never land loose.
|
||||
- Imported files are untrusted: sizes, entity counts and nesting are capped, and every kernel call is guarded (`skills/solidcraft-crash-safety/SKILL.md`). What cannot be read becomes a warning on the feature.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
{
|
||||
"id": "86-plate-first-bore-12",
|
||||
"name": "Workshop plate after Bore1 diameter changes to Ø12",
|
||||
"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": "12 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": 6,
|
||||
"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=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",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 158429.2036732051,
|
||||
"area_mm2": 23671.238898038468,
|
||||
"center_of_mass": [
|
||||
49.900851844838215,
|
||||
39.900851844838215,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 7
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "87-plate-bore1-deleted",
|
||||
"name": "Workshop plate retaining Bore2 after Bore1 deletion",
|
||||
"description": "Plate 100 x 80 x 20 mm after deleting Bore1, keeping the original Bore2 identity and XY (60,50), Ø10.",
|
||||
"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_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_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": "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=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",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 117989.38070170255,
|
||||
"area_mm2": 29203.185789489246,
|
||||
"center_of_mass": [
|
||||
74.99999999999999,
|
||||
40,
|
||||
5
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
150,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
"faces": 10
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"id": "88-plate-four-bores",
|
||||
"name": "Workshop plate with four Ø8 through bores",
|
||||
"description": "Parameterized rectangular workshop plate 150 x 80 x 10 mm with 4 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": "150 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "thickness",
|
||||
"expression": "10 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore1_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore2_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore3_d",
|
||||
"expression": "8 mm",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"name": "bore4_d",
|
||||
"expression": "8 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": [
|
||||
150,
|
||||
0
|
||||
]
|
||||
},
|
||||
"id": "l1"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l1.end",
|
||||
"at": [
|
||||
150,
|
||||
0
|
||||
]
|
||||
},
|
||||
"end": {
|
||||
"at": [
|
||||
150,
|
||||
80
|
||||
]
|
||||
},
|
||||
"id": "l2"
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": {
|
||||
"ref": "l2.end",
|
||||
"at": [
|
||||
150,
|
||||
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": [
|
||||
15,
|
||||
15
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_2",
|
||||
"center": {
|
||||
"at": [
|
||||
135,
|
||||
15
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_3",
|
||||
"center": {
|
||||
"at": [
|
||||
15,
|
||||
65
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"construction": true
|
||||
},
|
||||
{
|
||||
"type": "circle",
|
||||
"id": "bore_circle_4",
|
||||
"center": {
|
||||
"at": [
|
||||
135,
|
||||
65
|
||||
]
|
||||
},
|
||||
"radius": 4,
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_3"
|
||||
],
|
||||
"value": "bore3_d",
|
||||
"unit": "mm"
|
||||
},
|
||||
{
|
||||
"type": "diameter",
|
||||
"refs": [
|
||||
"bore_circle_4"
|
||||
],
|
||||
"value": "bore4_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": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
15,
|
||||
15,
|
||||
10
|
||||
],
|
||||
"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": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
135,
|
||||
15,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore2_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore3",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
15,
|
||||
65,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore3_d",
|
||||
"extent": "through_all"
|
||||
},
|
||||
{
|
||||
"op": "hole",
|
||||
"name": "Bore4",
|
||||
"hole_type": "simple",
|
||||
"face": {
|
||||
"type": "plane",
|
||||
"normal_at_point_on_face": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"plane_normal_outward": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"point": [
|
||||
75,
|
||||
40,
|
||||
10
|
||||
]
|
||||
},
|
||||
"position": [
|
||||
135,
|
||||
65,
|
||||
10
|
||||
],
|
||||
"direction": "into the material (-Z)",
|
||||
"diameter": "bore4_d",
|
||||
"extent": "through_all"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: 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",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 155141.5926535898,
|
||||
"area_mm2": 23627.433388230813,
|
||||
"center_of_mass": [
|
||||
50.20249841450349,
|
||||
40.10124920725174,
|
||||
10
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 12
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"id": "94-plate-four-rounded-corners",
|
||||
"name": "Workshop plate with four R10 vertical corner rounds",
|
||||
"description": "100 x 80 x 20 mm workshop plate, Ø10 through-bores, four R10 vertical corner rounds.",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance",
|
||||
"chamfer"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"op": "fillet",
|
||||
"name": "FourVerticalCornerRounds",
|
||||
"type": "constant_radius",
|
||||
"radius": 10,
|
||||
"tangent_chain": false,
|
||||
"edge_selection": "All four vertical exterior corner edges of the plate; far from both through bores",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
80,
|
||||
10
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: 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",
|
||||
"body_count": 1,
|
||||
"volume_mm3": 154464.30652785578,
|
||||
"area_mm2": 23220.575727260624,
|
||||
"center_of_mass": [
|
||||
50.20338631779784,
|
||||
40.10169315889891,
|
||||
9.959084781124005
|
||||
],
|
||||
"bbox": {
|
||||
"min": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
]
|
||||
},
|
||||
"faces": 20
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
{
|
||||
"id": "95-plate-rounded-top-chamfer",
|
||||
"name": "Workshop plate with four R10 corner rounds and full upper 2 mm chamfer",
|
||||
"description": "100 x 80 x 20 mm workshop plate, Ø10 through-bores, four R10 vertical corner rounds and full upper perimeter 2 mm chamfer.",
|
||||
"exercises": [
|
||||
"extrude",
|
||||
"hole_through_all",
|
||||
"drawing_w23_acceptance",
|
||||
"chamfer",
|
||||
"fillet"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"op": "fillet",
|
||||
"name": "FourVerticalCornerRounds",
|
||||
"type": "constant_radius",
|
||||
"radius": 10,
|
||||
"tangent_chain": false,
|
||||
"edge_selection": "All four vertical exterior corner edges of the plate; far from both through bores",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
0,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
100,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
100,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
100,
|
||||
80,
|
||||
10
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
0,
|
||||
80,
|
||||
0
|
||||
],
|
||||
"end": [
|
||||
0,
|
||||
80,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
0,
|
||||
80,
|
||||
10
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"op": "chamfer",
|
||||
"name": "FullTopPerimeterChamfer",
|
||||
"type": "equal_distance",
|
||||
"distance": 2,
|
||||
"tangent_chain": true,
|
||||
"edge_selection": "Front top straight edge after all vertical corners are rounded; tangent follow the complete top loop",
|
||||
"edges": [
|
||||
{
|
||||
"type": "line",
|
||||
"start": [
|
||||
10,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"end": [
|
||||
90,
|
||||
0,
|
||||
20
|
||||
],
|
||||
"point": [
|
||||
50,
|
||||
0,
|
||||
20
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
# W23a — workshop plate fixtures and future Drawing acceptance (#4950)
|
||||
|
||||
This contribution contains **geometry oracle recipes only**. Each is a reproducible
|
||||
SolidCraft 3D model with independently calculated body count, volume, surface
|
||||
area, centre of mass and bounds. Drawing records, persistent references, UI
|
||||
commands, drawing fonts, HLR and file exporters are **not** implemented here.
|
||||
|
||||
Run `python3 tools/oracle-analytic/cases_workshop.py` to regenerate the
|
||||
analytic `expected.json` values; run `cargo xtask oracle` to replay every
|
||||
reference case through SolidCraft's CLI. Formula sources and method strings are
|
||||
in the script and the corresponding expected-value files.
|
||||
|
||||
## Exact geometry and states
|
||||
|
||||
The W23/M2 checkpoint baseline is a **100 × 80 × 20 mm** plate whose lower-left
|
||||
model corner is at XY (0, 0). Two independent through-hole features have
|
||||
identical Ø10 diameters and are 50 mm apart:
|
||||
|
||||
| Persistent feature | Model XY centre (mm) | Model diameter (mm) |
|
||||
| --- | --- | --- |
|
||||
| `Bore1` (A) | (20, 20) | 10 |
|
||||
| `Bore2` (B) | (60, 50) | 10 |
|
||||
|
||||
The model parameters are `length`, `width`, `thickness`, `bore1_d` and
|
||||
`bore2_d`; each hole is a separately created feature. The stock's sketch
|
||||
dimensions drive its length and width, and its extrusion depth is `thickness`.
|
||||
Each bore also has a construction-circle guide in the stock sketch with a
|
||||
diameter dimension driven by its corresponding `boreN_d` parameter. The
|
||||
individual `solid.hole` features use the same parameters and remain separate
|
||||
CAD operations; construction guides do not cut the stock.
|
||||
In an actual interactive workflow, the parameter edit is one command, not a
|
||||
rebuild from an unrelated set of fixed numbers.
|
||||
|
||||
| Oracle case | Model state (mm) | What this verifies |
|
||||
| --- | --- | --- |
|
||||
| `84-plate-two-bores` | 100 × 80 × 20; `Bore1` Ø10 at (20,20); `Bore2` Ø10 at (60,50) | Baseline; separate references |
|
||||
| `85-plate-width-100` | 100 × 100 × 20; both bores as in 84 | Only driving `width` 80 → 100 |
|
||||
| `86-plate-first-bore-12` | 100 × 80 × 20; `Bore1` Ø12; `Bore2` Ø10 | Only `bore1_d` 10 → 12 |
|
||||
| `87-plate-bore1-deleted` | 100 × 80 × 20; only original `Bore2` Ø10 at (60,50) | Delete `Bore1`, retain `Bore2` identity |
|
||||
| `88-plate-four-bores` | 150 × 80 × 10; four Ø8 bores at (15,15), (135,15), (15,65), (135,65) | Independent four-hole workshop plate |
|
||||
| `94-plate-four-rounded-corners` | 100 × 80 × 20; both Ø10 bores; **four vertical R10 exterior corner rounds** | Rounded exterior profile |
|
||||
| `95-plate-rounded-top-chamfer` | As 94, **plus a 2 mm equal-distance chamfer along the entire tangent-connected upper perimeter** | Rounded corners and chamfered upper edges |
|
||||
|
||||
The R10 corner cuts and full upper-perimeter 2 mm chamfer are analytic using
|
||||
the same quarter-spandrel and tangent-loop perimeter mathematics as oracle
|
||||
case 33. These revised blends require a fresh full oracle replay before they
|
||||
can be called verified.
|
||||
|
||||
## A3 at 1:2 — geometry versus measured dimension values
|
||||
|
||||
For the model in case 84, an A3 landscape drawing is **420 × 297 mm**,
|
||||
scale **1:2**. Its projected plate envelope on the sheet is **50 × 40 mm**;
|
||||
each Ø10 hole appears as a Ø5 circle and the 50 mm centre separation projects
|
||||
to 25 mm. **Annotation values remain in model units**: plate length 100 mm,
|
||||
plate width 80 mm, `Bore1` Ø10 mm, centre-to-centre 50 mm. Their written
|
||||
values are not halved by the view scale. Actual text metrics and display
|
||||
precision follow their eventual upstream owners (#4813 and #4896).
|
||||
|
||||
## Acceptance sequence once drawing APIs are available
|
||||
|
||||
Every independent edit starts from case 84; the cases in this table are
|
||||
**reference states**, not a claim that these drawing commands exist today.
|
||||
|
||||
| Step / intended command | Owning session | Resulting source oracle state | Expected drawing measurements and labels (after Update if deferred) |
|
||||
| --- | --- | --- | --- |
|
||||
| Create baseline geometry from the case 84 recipe | **Source design** | 84 | Two independent bores and the original width 80 geometry |
|
||||
| Add a `Bore1` diameter, centre-distance and plate-width dimension | **Drawing** | 84 (unchanged) | Ø10, 50, width 80 |
|
||||
| Move the dimension labels to chosen paper-mm coordinates; save and reopen the drawing | **Drawing** | 84 (unchanged) | Every label remains at its user-chosen paper coordinate |
|
||||
| `parameters.change` `width` 80 → 100 | **Source design** | 85 | Width becomes 100; Ø10 and centre distance 50 remain; all label positions unchanged |
|
||||
| `parameters.change` `bore1_d` 10 → 12 | **Source design** | 86 | `Bore1` becomes Ø12; centre distance 50 and width 80 remain; labels unchanged |
|
||||
| `timeline.delete Bore1` | **Source design** | 87 | `Bore2` remains Ø10 with its original identity. `Bore1` diameter and linked centre-distance dimensions become **lost, kept with their last values**; no silent transfer to `Bore2` |
|
||||
| `edit.undo` immediately after any individual source edit | **Source design** | 84 | Baseline measurements Ø10 / 50 / width 80 and original reference state, after drawing Update |
|
||||
| `edit.redo` following that source undo | **Source design** | 85, 86 or 87, matching the undone edit | Edited measurements and corresponding lost/valid reference state, after drawing Update; manually placed labels retain their paper positions |
|
||||
| Update an out-of-date view in deferred mode | **Drawing** (cache update; **no new undo step**) | No source change | Drawing displays the current source state; label positions remain unchanged |
|
||||
| Undo/redo a label move or an explicit reference repair | **Drawing** | No source change | Reverts/reapplies only the drawing edit; must never undo a source model change |
|
||||
|
||||
Both **open-drawing** and **closed-drawing** deferred-update sequences must
|
||||
be tested when the shared drawing commands land:
|
||||
|
||||
1. **Drawing open:** Change a driving parameter. The existing drawing and
|
||||
labels stay visibly **out of date until Update**; executing Update obtains
|
||||
the new values and retains manual label positions.
|
||||
2. **Drawing closed:** Save and close the drawing, change the 3D model, then
|
||||
reopen it. The saved drawing still appears out of date until Update;
|
||||
updating does not reset manual label positions.
|
||||
3. Repeat both sequences for bore changes, deletion and **source-design**
|
||||
undo/redo. Each source change, including its undo/redo, leaves a deferred
|
||||
drawing **out of date until Update**; Update refreshes the view cache
|
||||
without creating an undo step in either session. With a lost reference,
|
||||
retain the last measured value explicitly as historical instead of
|
||||
reporting it as a current measurement. A replacement bore at the same XY
|
||||
and diameter must not silently restore old source identity; reference
|
||||
repair is an explicit **drawing-session** operation.
|
||||
4. Undo/redo drawing-session label moves and reference repairs separately;
|
||||
neither action may undo a source design's parameter edit or deletion.
|
||||
|
||||
The coordinated drawing design allows **automatic and deferred** update
|
||||
modes (#4892). Do not introduce a separate rejected-update contract here.
|
||||
Native storage, update commands and undo ownership belong to #3890 and
|
||||
#4892; annotations and numeric formats to #4896; Drawing UI to #4894;
|
||||
sheet geometry/export to #4890. Add executable engine, desktop UI, browser
|
||||
and SVG/DXF/PDF/PNG comparisons only after those APIs are implemented.
|
||||
|
||||
## Analytic provenance
|
||||
|
||||
All source values use independent closed-form mathematics. For rectangular
|
||||
plates, volume is `LWT − Σπ(d/2)²T`, and surface area includes exposed
|
||||
hole walls minus circles on both stock faces. For the four vertical R10
|
||||
rounds, the section loses `(4−π)R²`; the boundary becomes
|
||||
`P = 2(L+W)−8R+2πR`. A 2 mm upper tangent-loop chamfer removes
|
||||
`V_c=Pc²/2−πc³/3` of material. The depth first moment is
|
||||
`Pc³/6−πc⁴/12`, so its removed-volume centroid is
|
||||
`z=T−(Pc³/6−πc⁴/12)/V_c`; the inward-offset perimeter shrinks with depth.
|
||||
Surface and first moments follow this exact construction.
|
||||
Calculations are regenerated by `tools/oracle-analytic/cases_workshop.py`.
|
||||
@@ -13,6 +13,7 @@ python3 tools/oracle-analytic/cases_features.py # 32-66: fillets, shells, sw
|
||||
python3 tools/oracle-analytic/cases_sheet_metal.py # 67-76: sheet metal
|
||||
python3 tools/oracle-analytic/cases_fillets.py # 77-81: variable, chord-length, tapered and spline fillets
|
||||
python3 tools/oracle-analytic/cases_surfaces.py # 83: boundary surfaces
|
||||
python3 tools/oracle-analytic/cases_workshop.py # W23 (#4950): parameterized plates, round corners and top chamfer
|
||||
```
|
||||
|
||||
Where a value depends on a SolidCraft convention rather than on geometry alone (a shell's sharp
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analytic expected-value writer for the W23 workshop reference plates.
|
||||
|
||||
Run from the repository root:
|
||||
python3 tools/oracle-analytic/cases_workshop.py
|
||||
|
||||
All dimensions are taken from the actual parameterized oracle recipes. The
|
||||
mass properties come from exact box/cylinder subtraction, rounded rectangle
|
||||
quarter-spandrels and the same tangent-loop chamfer integration as case 33.
|
||||
No SolidCraft result is used to create any expected geometry value.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from cases_features import MP, PI, box, cyl, spandrel_area, write
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2] / "tests" / "oracle"
|
||||
CASES = (
|
||||
"84-plate-two-bores",
|
||||
"85-plate-width-100",
|
||||
"86-plate-first-bore-12",
|
||||
"87-plate-bore1-deleted",
|
||||
"88-plate-four-bores",
|
||||
"94-plate-four-rounded-corners",
|
||||
"95-plate-rounded-top-chamfer",
|
||||
)
|
||||
|
||||
|
||||
def one(case):
|
||||
recipe = json.loads((ROOT / case / "recipe.json").read_text())
|
||||
params = {
|
||||
p["name"]: float(p["expression"].removesuffix(" mm"))
|
||||
for p in recipe["parameters"]
|
||||
}
|
||||
length, width, thickness = (params[n] for n in ("length", "width", "thickness"))
|
||||
bores = [
|
||||
(f["name"], float(f["position"][0]), float(f["position"][1]),
|
||||
params[f["diameter"]])
|
||||
for f in recipe["features"] if f["op"] == "hole"
|
||||
]
|
||||
rounded = next((f for f in recipe["features"] if f["op"] == "fillet"), None)
|
||||
chamfer = next((f for f in recipe["features"] if f["op"] == "chamfer"), None)
|
||||
perimeter = 2 * (length + width)
|
||||
top_area = length * width
|
||||
solid = box(0, 0, 0, length, width, thickness)
|
||||
|
||||
if rounded is not None:
|
||||
# Four R10 vertical edges remove the same quarter-square-minus-circle
|
||||
# cross-section from each corner. Symmetry locates their combined COM.
|
||||
r = float(rounded["radius"])
|
||||
assert 0 < r < min(length, width) / 2
|
||||
removed_xy = 4 * spandrel_area(r)
|
||||
solid -= MP.at(removed_xy * thickness,
|
||||
[length / 2, width / 2, thickness / 2])
|
||||
top_area -= removed_xy
|
||||
perimeter -= 8 * r - 2 * PI * r
|
||||
|
||||
if chamfer is not None:
|
||||
# Constant-distance top chamfer around the entire rounded perimeter.
|
||||
# At depth y below the top, the removed band has area
|
||||
# P*(c-y) - pi*(c-y)^2: the inset perimeter shortens with depth.
|
||||
# Integrate this band for exact removed volume and Z first moment.
|
||||
c = float(chamfer["distance"])
|
||||
assert rounded is not None and 0 < c < rounded["radius"]
|
||||
removed = perimeter * c ** 2 / 2 - PI * c ** 3 / 3
|
||||
mean_depth = (perimeter * c ** 3 / 6 - PI * c ** 4 / 12) / removed
|
||||
solid -= MP.at(removed, [length / 2, width / 2,
|
||||
thickness - mean_depth])
|
||||
area = (2 * top_area + perimeter * thickness
|
||||
- c * perimeter + PI * c * c
|
||||
- c * perimeter + math.sqrt(2) * c * (perimeter - PI * c))
|
||||
else:
|
||||
area = 2 * top_area + perimeter * thickness
|
||||
|
||||
holes_desc = []
|
||||
for name, x, y, diameter in bores:
|
||||
radius = diameter / 2
|
||||
solid -= cyl(x, y, radius, 0, thickness)
|
||||
area += PI * diameter * thickness - 2 * PI * radius * radius
|
||||
holes_desc.append(f"{name} at ({x:g},{y:g}) Ø{diameter:g}")
|
||||
|
||||
detail = ("rectangular" if rounded is None else
|
||||
f"four exterior vertical R{rounded['radius']:g} corner rounds")
|
||||
if chamfer:
|
||||
detail += f"; full upper tangent loop {chamfer['distance']:g} mm chamfer"
|
||||
method = (f"Plate L={length:g}, W={width:g}, T={thickness:g} mm; "
|
||||
f"{'; '.join(holes_desc)}. 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: " + detail)
|
||||
if rounded:
|
||||
method += ("; 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")
|
||||
if chamfer:
|
||||
method += ("; 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")
|
||||
# Every curved corner is one new vertical side face, in addition to
|
||||
# four remaining straight sides, two planar caps and independent bores.
|
||||
# A complete tangent-chain top chamfer contributes eight more faces:
|
||||
# one over each of the four straight runs and four R10 corner arcs.
|
||||
faces = 6 + len(bores) + (4 if rounded else 0) + (8 if chamfer else 0)
|
||||
write(case, method, 1, solid, area,
|
||||
[(0, 0, 0), (length, width, thickness)], faces=faces)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for fixture in CASES:
|
||||
one(fixture)
|
||||
Reference in New Issue
Block a user