mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* Point clouds: insert PLY, XYZ and PTS scans as a modelling reference (#5151) A point cloud is kept in the design as a reference, drawn as depth-tested dots and never a body. - io: a PLY reader (ASCII, binary little/big endian; x, y, z of any numeric type, optional colours, other elements skipped; counts checked against the file) and XYZ/PTS text, thinned evenly past max_points. PLY meshes with faces can also come in through file.insert_mesh. - doc: Document.point_clouds (shared f32 points relative to an f64 origin, colours, units, rigid placement, size and colour), stored as a binary container part; format change in pending/point-clouds.rs. - engine: pointcloud.insert / edit / delete / list; doc.open opens a cloud as a new design; selection.delete takes point_clouds; view bounds include them. - gpu: an instanced point-sprite pipeline (16 bytes a point). - ui: Browser Point Clouds folder with eyes, menu and an Edit Point Cloud card; drop, Open, Insert and file dialogs take .ply/.xyz/.pts; a CPU viewport fallback; messages in en, en-GB, ja, zh, de and ko. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Point clouds: colour by the file, one colour or height, from the UI (#5151) The Edit Point Cloud card gets Color By (File Colors, Single Color, Height). Height is a blue-to-red ramp over design Z, between the cloud's own two heights or, by default, a range worked out from the points (all but the lowest and highest 2 %, from an even sample). pointcloud.insert and pointcloud.edit take color_by and height_range; pointcloud.list reports them. The colour of each point is worked out in one place (PointCloud::shade) for the GPU and CPU viewports. Also: the card's dropdowns compare the picked index (a dropdown's response doesn't report the pick, so File Units did not change either). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Point clouds: integrate format registration and viewport caches (#5151) * Point clouds: satisfy current UI audits and document comparisons (#5151) Route model RGB data through the theme conversion boundary, provide a compact ribbon label, and include point clouds in the new document comparison. Gate the Unix-only CLI-link helper and test so Windows all-target checks remain buildable after the rebase. * Point clouds: isolate translations for current main (#5151) Move the existing 74 messages unchanged into one pending locale file and attribute it through a pending row, following the current repository workflow. Repository checks, 50 tooling tests, final workspace Clippy, wasm and generated-catalog equality checks passed after the rebase. * Point clouds: fit the rebased code to main's GpuTarget and Frame Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Compare point clouds when reusing placement-independent results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Point clouds: complete command help and required translations (#5151) * Point clouds: size the save cap from the raised limit after the rebase Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
+694
@@ -80,3 +80,697 @@ permanently.
|
||||
- Discord: <https://discord.gg/FtU4NQNVDH>
|
||||
- Releases: <https://github.com/tangency-labs/solidcraft/releases>
|
||||
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
|
||||
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; FreeCAD/Rhino import; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | [Current case count and results](docs/oracle.md): public cases in `tests/oracle/` with analytic or FreeCAD-computed expected values, checked by the reference-test job in [CI](.github/workflows/ci.yml). |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
|
||||
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
|
||||
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
|
||||
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
|
||||
|
||||
## Scope (owner decision, 2026-10-07)
|
||||
|
||||
The issue batch adds preservation of blind pockets in through cuts, associative projected
|
||||
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
|
||||
so edge-touching assembly neighbours stay separate regardless of body order (#226).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
2. Parameters / functional constraints (done: see `parameters.*`, `expr.evaluate`; unit-aware,
|
||||
cycles reported with their path, rename follows references, deletes list their users):
|
||||
user parameters with units and expressions (+-*/, ^,
|
||||
parentheses, sin/cos/sqrt/min/max/floor/ceil/round, unit conversion), model parameters
|
||||
d1, d2… for every dimension and feature input, references between parameters with a
|
||||
dependency graph and cycle detection, re-evaluation on change, comments, favourites,
|
||||
CSV/JSON import and export.
|
||||
3. Components and assemblies with joints (M9).
|
||||
4. SHEET METAL: flange, contour flange, bend, unfold/refold, flat pattern with DXF export, sheet
|
||||
metal rules (thickness, K-factor, bend radius) (M10).
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), drawing snaps to where two lines, circles or arcs cross (#4967), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop, browser and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import, PLY/XYZ point clouds as a modelling reference and PLY meshes (done — [docs/point-clouds.md](docs/point-clouds.md), #5151) | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; Browser Expand All / Collapse All (`browser.expand_all`, `browser.collapse_all`) on the design and component menus; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane, a face or sketch curves swept onto the surface, keeping a side or the picked regions; mutual trim of two surfaces, extended to meet and stitched, #767), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764: edge chains along one or several faces, closed rims paired seam to seam or from picked seams, a dialog), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown, and guide rails on solid and surface lofts (one or two, scaling or stretching the sections between; #201). Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), and every remaining #757–#772 item | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements, and exported as an animation coming apart and back together); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
|
||||
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
|
||||
| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 |
|
||||
|
||||
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
|
||||
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers
|
||||
orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler
|
||||
applies the same gestures to model and Render views and blocks orbit on flat pages.
|
||||
|
||||
UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation,
|
||||
action tiles, recent rows and sample cards, with recovery review and Save As/Export
|
||||
entry points. Document loading, sample caches and recovery safety retain their
|
||||
existing command paths.
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies and sketches from the current
|
||||
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
|
||||
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
|
||||
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Drawing foundations: [RFC #860](https://github.com/tangency-labs/solidcraft/issues/860)
|
||||
and the [foundation ledger](docs/drawing-foundations.md) track the bounded implementation
|
||||
packages. The first geometry increment defines validated section records in a plane-local
|
||||
frame; the ledger separates that contract from section producers and the Drawing workspace.
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- Modelled threads preview their cut before OK using the background worker on desktop.
|
||||
Browser users apply the cut with OK to avoid blocking on every edit.
|
||||
|
||||
- Thread resolves a suggested metric size when a cylindrical face is picked. Diameter and
|
||||
pitch selectors expose the existing metric designation parameter; custom designations stay
|
||||
editable, and Automatic size returns to the face's suggestion. Cosmetic/modelled and
|
||||
full/partial-length options remain available. This does not add tolerance classes or
|
||||
left-hand threads.
|
||||
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||
follow the current frame's input.
|
||||
- Large fine modelled threads use pitch-based helix resolution and tighter faceted
|
||||
reconstruction fallbacks, preserving thin crests and closed shells on large shafts.
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
thermal data, per-property sources/conditions and isotropic/orthotropic stability
|
||||
checks through read-only `material.properties.validate`. It does not assign or
|
||||
persist properties, provide a solver or enable rendering; see
|
||||
[the contract and staged follow-ups](docs/physical-material-cards.md).
|
||||
|
||||
- Directional elasticity (#994) queries the validated card in material-local axes
|
||||
through read-only `material.properties.directional`, with source/condition review.
|
||||
It does not assign body frames, predict structural behavior or enable a solver.
|
||||
|
||||
## Library foundations (#785, #786, #787)
|
||||
|
||||
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
|
||||
selected appearance/material revisions into existing design definitions in one undo step.
|
||||
Appearance offers local loading/search/export; physical materials use existing commands.
|
||||
This slice is independent of the render stack and does not change the document format.
|
||||
See [the contract and limits](docs/book/src/dev/local-libraries.md).
|
||||
|
||||
- Proposed phases cover offline bundled/project/user packs, physical materials and
|
||||
appearances, editable wood mapping, and parameterized component/assembly families.
|
||||
Stable identity/revision, copied design definitions, provenance and resource limits
|
||||
apply from the first implementation. Existing material, appearance and standard-part
|
||||
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
|
||||
for source/license review and acceptance gates. This is planning only: pack loading,
|
||||
texture rendering and expanded component families are not implemented by this entry.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
|
||||
|
||||
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
|
||||
|
||||
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
|
||||
|
||||
- Explicit texture normalization (#1218) produces completed float RGBA with
|
||||
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
|
||||
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
|
||||
gamut clipping only when explicitly requested. It is not a renderer/resource
|
||||
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
|
||||
|
||||
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
|
||||
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
|
||||
stay unchanged. HDRI, transmission and textures remain separate gates.
|
||||
See [the consumer and limitations](docs/render-opaque-pbr.md).
|
||||
|
||||
- Combined integration is tracked in #842, with independent scalar libraries in
|
||||
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
|
||||
Render/library consumers use the merged asynchronous picker (#1127). Material-card
|
||||
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
|
||||
|
||||
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
|
||||
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
|
||||
results stay outside persistence/undo; native workers and cooperative browser tasks
|
||||
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
|
||||
|
||||
- R3 adds an overlay-free product scene and the integrated Render workspace, with
|
||||
runtime GPU host capabilities, per-document drafts and named camera/background
|
||||
controls. Design keeps its camera and drawing policy. Raster transparency remains
|
||||
approximate; PBR and environments are later slices. See
|
||||
[workspace and scene contracts](docs/render-workspace.md).
|
||||
|
||||
- R2 adds bounded named render setups in document format 21 and the read/save
|
||||
`render.settings.get/set` commands. Proven render-only changes preserve geometry
|
||||
and prepared world data through undo/redo and checkpoint restore. This slice does
|
||||
not add a Render workspace, PBR, environments or image jobs; see
|
||||
[settings and refresh policy](docs/render-settings.md).
|
||||
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
|
||||
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
|
||||
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
|
||||
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
|
||||
|
||||
- The initial opaque surface numerical contract for #1004 provides bounded linear
|
||||
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
|
||||
The numerical module alone advertises no capabilities; the installed consumer above
|
||||
provides rendering. See
|
||||
[the model, evidence and remaining consumer gates](docs/render-surface.md).
|
||||
|
||||
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
|
||||
it does not yet enable HDRI/PBR rendering or a document import command. See
|
||||
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
|
||||
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
|
||||
bounded image/scratch admission checks, exposed by the read-only `render.backends`
|
||||
command. It describes the current CPU raster snapshot path without evaluating the model.
|
||||
The legacy Design snapshot route remains unchanged. Later slices above provide the
|
||||
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
|
||||
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Operation length fields share design-unit defaults and input evaluation, including secondary
|
||||
distances, thin walls, blend controls, sheet/plastic rules, joints and motion keys. Canvas
|
||||
handles and sketch previews use the same lengths as committed geometry. Existing stored
|
||||
millimetre values retain their size when edited in a design using another unit (#3271).
|
||||
|
||||
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
|
||||
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
|
||||
Both tools retain full replies by default.
|
||||
|
||||
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
|
||||
Polygon and Spline. Each open part remembers its last interactive choice per family
|
||||
across sketch sessions and document-tab switches; closing the part resets these UI choices.
|
||||
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
|
||||
placement, including orientation and the selected side count.
|
||||
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
|
||||
dimension values, so line type can change while a shape is in progress.
|
||||
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
|
||||
committed geometry; centerlines retain their dash-dot pattern.
|
||||
|
||||
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
|
||||
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
|
||||
Interior selection requires every profile boundary to belong to the same text source;
|
||||
the editor highlights the whole source without replacing profile selections.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
Dragging a text outline moves the entire source in one undo step; Escape restores the
|
||||
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
|
||||
about its baseline; copies keep an independent editable text source.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
|
||||
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
|
||||
associative axis expressions and construction-axis references remain unsupported.
|
||||
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
|
||||
Type a value and press Enter to commit one undo step; rejected values remain editable and
|
||||
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
|
||||
and navigation controls, including enlarged displays and nested pending placements.
|
||||
- Sketch Dimension takes a line and a circle or arc (#1803): it measures from the line to the
|
||||
circle's edge (the gap, 0 when tangent, or how far the edge reaches past a line that cuts
|
||||
it), drives it like any other dimension and keeps the circle on its side of the line.
|
||||
- Sketch Dimension takes a point and a circle or arc, or two circles or arcs (#4064): it
|
||||
measures to the circle's edge (from the point, or between the two edges: the gap, 0 when
|
||||
they touch, how far they overlap, or the narrowest gap between nested circles).
|
||||
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
|
||||
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
|
||||
the current design unit; changing units leaves existing geometry and expressions intact.
|
||||
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
|
||||
and pocket depths, interference volumes and parts-library lengths also use design units.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
|
||||
reporting for unexpected results that did not produce an error.
|
||||
- The header exposes **Report a bug**, including an accessible compact control on
|
||||
narrow windows. It opens the same editable report and screenshot review without
|
||||
changing the active model or a pending dimension.
|
||||
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
|
||||
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
|
||||
journal) leaves a local report with the backtrace, version and commit, the command and the
|
||||
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
|
||||
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
|
||||
Crash Reports…) with a reviewed issue draft; agents read them through
|
||||
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
|
||||
anywhere. See [crash reports](docs/crash-reports.md).
|
||||
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
|
||||
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
|
||||
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
|
||||
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
|
||||
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
|
||||
the agent's whole process group ends with the panel or the app. Docks left or right,
|
||||
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
|
||||
Grok login. See [agent chat](docs/agent-chat.md).
|
||||
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
|
||||
writes a design's history as a script that `eval` and `run` replay into the same design —
|
||||
user parameters, units, sheet metal and plastic rules and materials as their own commands,
|
||||
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
|
||||
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
|
||||
script after a step gives the design at that step. Appearances, folders, views and
|
||||
configurations are listed as left out; components and joints are not supported yet.
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
|
||||
durable supported profile/face references, endpoint dimensioning and contextual reporting.
|
||||
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
|
||||
authored splits and actual creases remain protected. Wireframe retains physical edges.
|
||||
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
|
||||
prisms with separated circular holes; unsupported shapes retain the existing strict check.
|
||||
- Common-bore Union retries check additional boundary curves before recutting the bore,
|
||||
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
|
||||
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
|
||||
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
|
||||
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
|
||||
web app and both Flatpak architectures were verified. The replacement README demo uses
|
||||
the original electronics enclosure story and animated pointer in the actual English
|
||||
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
|
||||
|
||||
## 0.2.1 critical fixes
|
||||
|
||||
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
|
||||
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
|
||||
extrusion previews build only their tools; confirmation validates the final Boolean.
|
||||
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
|
||||
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
|
||||
computes on a scratch session in the background, with atomic acceptance and cancellation.
|
||||
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
|
||||
- Imported rotated face groups can check all full start-face seams in one guarded
|
||||
construction. Separated tools may batch a Join when each meets the same single body;
|
||||
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
|
||||
cover all four extrusion operations.
|
||||
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
|
||||
Safe interior classification handles roundoff at shared imported line/arc endpoints.
|
||||
Straight extrusion feedback caches existing face/profile triangles and updates the display
|
||||
directly, including a newly opened STEP's single identity component placement; other
|
||||
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
|
||||
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
|
||||
Confirmation and tool previews use main's shared background calculation. Main's collapsed
|
||||
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
|
||||
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
|
||||
the command palette open, at normal and enlarged display scales. Fit still accounts for
|
||||
dialog coverage; covered controls do not receive clicks through dialogs.
|
||||
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
|
||||
certificates. Sketches placed directly on the enclosure wall and inner floor follow
|
||||
width and wall changes, including after the USB cut and cold reopening.
|
||||
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
|
||||
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
|
||||
face, with exact full-height supports and boundary stitching. Preserve authored face
|
||||
splits and face appearances. Permanent regressions cover material, STEP, reopening and
|
||||
unaffected bodies across the extrusion end plane.
|
||||
- Feature dialogs use the available screen height for their fields, with OK and Cancel
|
||||
outside the scrolling region, including enlarged display scales.
|
||||
- Design format 13 records shell-face attachments. Older designs remain readable;
|
||||
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
|
||||
attachment rather than guessing a plane.
|
||||
- Saved projections from a removed strip retain their geometry and report the lost
|
||||
reference. Explicitly reselect the wall to repair that projection.
|
||||
|
||||
## Workspace layouts (#2029)
|
||||
|
||||
- General preferences save Floating or Compact using the universal UI containers.
|
||||
Compact joins the workspace selector and ribbon, docks the content-hugging Browser
|
||||
flush beneath them with a square join, and opens tool sheets below the compass.
|
||||
Sheets remain draggable, rounding all corners away from their right dock; footer
|
||||
backgrounds follow the same corners. A separate preference hides ribbon icon labels
|
||||
for a shorter ribbon in either layout.
|
||||
Assistant keeps the outermost edge and History keeps its chosen dock. Switching
|
||||
retains the active tool, camera, selection and design; system windows and menus
|
||||
keep their existing behavior.
|
||||
|
||||
## View navigation
|
||||
|
||||
- Navigation has an opt-in Free orbit preference for mouse and 3D-mouse rotation through
|
||||
both poles with roll. It defaults off, preserving existing preset snapping and pole limits.
|
||||
The SolidWorks face menu offers Normal to Selection (`view.normal_to`), shared with Look At
|
||||
and Ctrl+8, including repeat-to-flip, preserving scale and projection.
|
||||
|
||||
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
|
||||
its layout stable during a drag and applies the selected scale on release.
|
||||
|
||||
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
|
||||
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
|
||||
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
|
||||
patches require separate upstream review because advisory databases cannot version-match
|
||||
their path dependencies. Pull requests retain the existing licence checks.
|
||||
|
||||
- Associative shell-face sketches require a unique certified planar source. Nested shell
|
||||
attachments and ambiguous coincident sources remain unsupported.
|
||||
- G2 fillets: exact quintic blends of straight edges between planar faces, with one planar
|
||||
end face at each end, including sloped ends (#2708). The UI and `solid.fillet` expose
|
||||
`curvature` (alias `g2`); the size is an equivalent circular setback. Curved faces/edges,
|
||||
multi-face ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
|
||||
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
curved chamfer chains with straight planar ends can terminate across two existing planar
|
||||
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight or mixed arc/line edges (straight
|
||||
blends meet in mitres; perpendicular curved walls use offset intersections, with D-rim removal
|
||||
checked within 0.1% of analytic offset slices), runs of
|
||||
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
|
||||
with the edge between them too, the corner becomes a sphere octant),
|
||||
rounded corners rounded again (sphere octants, tori at concave corners); every edge of a
|
||||
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
|
||||
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
|
||||
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
|
||||
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
|
||||
between two overlapping extruded arcs); knife edges, where the two faces have opposite
|
||||
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
|
||||
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
|
||||
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
|
||||
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
|
||||
other, except a round after a round of the edge between the wall and a flat face that it
|
||||
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
|
||||
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
|
||||
curves is refused as too large rather than returning an unmeshable face (#1113).
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
|
||||
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
|
||||
curved wall qualification rejects false transverse axes instead of filling holes.
|
||||
Bodies touching along one whole face
|
||||
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
|
||||
their neighbours are planes or cylinders. Other coincident curved configurations can still
|
||||
fail. Bounded containment classification preserves fully internal cavities and nested
|
||||
material islands independently of shell order, with STEP round-trip and material-membership
|
||||
regressions. Near-contact curved shells use the general boolean path rather than a
|
||||
containment shortcut. Spheres are built from six pole-free patches.
|
||||
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
|
||||
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
|
||||
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
|
||||
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
|
||||
offset surfaces and pcurve-only edges are not read yet,
|
||||
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
|
||||
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
|
||||
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
|
||||
references resolve by name first, follow splits and merges, and warn when the entity split,
|
||||
merged or vanished, or was found by geometry only (operations that report no history yet).
|
||||
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
|
||||
source certificates with checked target and author occurrence paths. A missing or changed
|
||||
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
|
||||
retaining current geometry and dimension identities. Unsupported modified faces need an
|
||||
explicit fixed plane. Legacy attachments retain their original resolver behavior.
|
||||
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
|
||||
and Revolve To Object on a curved face cut the tool back to the target's surface.
|
||||
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
|
||||
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
|
||||
touch (no clean crossing) enter that profile as polylines.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
|
||||
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
|
||||
walls through denser stations where the path has corners or the profile has holes.
|
||||
Guide rails (one or two, #771) do the same: each station meets every rail once, and the rail
|
||||
turns the profile and scales it (or stretches it across to the rail) against the start; two
|
||||
rails carry it between them. Rails along a path with sharp corners are refused for now.
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs.
|
||||
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
|
||||
speeds without changing stored nodes or relaxing the distance tolerance (#971).
|
||||
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
|
||||
editable source updates the derived boundary live. Break Association restores point editing.
|
||||
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
|
||||
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
|
||||
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
|
||||
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
|
||||
Newly captured adaptive profile references with complete source coverage retain their outer
|
||||
and hole identities through span-count changes, including parameter edits after extrusion.
|
||||
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
|
||||
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
|
||||
reselection. Format 12 stores the new bounded whole-boundary references.
|
||||
Projected analytic boundaries follow nested loop identities through resizing, including
|
||||
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
|
||||
can lose or regain corner arcs during an inset while preserving reserved curve identities
|
||||
and linked profile selections. Format 12 saves those associations; compatible older closed
|
||||
offsets upgrade structurally without changing their curve IDs. Breaking an association
|
||||
freezes its current boundary and materializes downstream profile references. Child offsets
|
||||
of a boundary with retired arcs still require breaking its association first; mixed or
|
||||
nonconvex junctions and retained references to disappearing individual arcs remain limited.
|
||||
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
|
||||
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
|
||||
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
|
||||
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
|
||||
use the sketch's actual target, author or uniquely placed third-component frame.
|
||||
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
|
||||
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
|
||||
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
|
||||
use the sketch-normal direction for each selected profile, including To Object and curved
|
||||
target trimming, independently of the selected axis sign.
|
||||
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
|
||||
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
|
||||
radial walls. New features infer a unique eligible cylinder when the face field is empty;
|
||||
ambiguous targets require an explicit face. Saved target
|
||||
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
|
||||
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
|
||||
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
|
||||
- Shell and offset faces work on bodies of planes, cylinders, cones, spheres and tori (convex or not:
|
||||
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
|
||||
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
|
||||
sections ruled. Circular-meridian barrels retain curved inner walls through either axial
|
||||
opening (#2421). Solid lofts preserve holes through sections with matching hole counts
|
||||
(#2435), including inner walls and caps with openings; a hole cannot appear or disappear
|
||||
between sections, and sections with holes cannot collapse to a point.
|
||||
|
||||
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
|
||||
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
|
||||
the origin; clicked UI regressions verify geometry, closed profiles and undo.
|
||||
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
|
||||
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
|
||||
invalidate the display cache without changing CAD geometry or timeline history. Work
|
||||
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
|
||||
The Sketch Palette scrolls its options on short windows and enlarged displays while
|
||||
keeping constraint status and Finish Sketch visible.
|
||||
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
|
||||
existing geometry before assigning positions, retaining saved curve IDs and constraints
|
||||
across enumeration changes and resize, including concentric circles. Ambiguous identities
|
||||
retain the last geometry and report a lost reference; permutation matching is bounded.
|
||||
- Project accepts complete supported preselection in one undo step. Visible external sketch
|
||||
curves and points retain their source sketch identity; model geometry, axes and planes use
|
||||
the existing linked projection commands. Rejection preserves the document and selection;
|
||||
mixed selections retain ordinary tool-first picking.
|
||||
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
|
||||
selecting visited values for replacement. Expression completion retains forward-Tab
|
||||
priority; real keyboard regressions cover rectangles, lines and single-value circles.
|
||||
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
|
||||
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
|
||||
rejected values keep the editable field focused.
|
||||
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
|
||||
checked occurrence paths. Active nested occurrences author planar sketches and solids in
|
||||
their selected frame. Pending moves compose through parents; dialog cancellation restores
|
||||
its selected move. Source materials and body/face appearances follow transformed instances.
|
||||
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
|
||||
independent feature/input histories, styles and internal motion records, with bounded geometry
|
||||
checks and explicit external-link notices. Paste New selects the copy while preserving the active
|
||||
authoring context. Imported periodic STEP seams survive placement and reexport with shared
|
||||
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
|
||||
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
|
||||
record their coordinate frame; legacy wires migrate from known provenance without changing
|
||||
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
|
||||
See [occurrence workflows](docs/occurrence-workflows.md).
|
||||
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
|
||||
button drops keep insert-before ordering.
|
||||
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
|
||||
Parameters opens the user/model variable editor; Configurations opens the configuration table.
|
||||
Configure remains available in the panel dropdown.
|
||||
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
|
||||
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
|
||||
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
|
||||
- Movable window title bars follow the app's dark/light header palette. Radial right-click
|
||||
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
|
||||
- Desktop distribution: running Windows windows and executable resources use the app icon;
|
||||
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
|
||||
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
|
||||
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
|
||||
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
|
||||
callbacks to prevent black frames. Other backends retain the requested sample count, and
|
||||
stored preferences remain unchanged.
|
||||
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
|
||||
restrict values. Preview explains effective values without modifying the document; applying
|
||||
rules validates rows atomically. Length expressions retain their authoring units across
|
||||
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
|
||||
Component styles and certified semantic face styles are available; unsupported lineage refuses
|
||||
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
|
||||
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
|
||||
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
|
||||
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
|
||||
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
|
||||
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
|
||||
sampling.
|
||||
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
|
||||
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
|
||||
exact holes and retained islands, including the union of multiple selected profiles, and
|
||||
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
|
||||
tapered or bend-crossing machining refuses atomically instead of restoring material later.
|
||||
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
|
||||
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
|
||||
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
|
||||
clear of the flange beside it, and the returns of one feature are mitred toward each other
|
||||
with the rule's gap where their flanges meet at a corner; returns of separate features, and
|
||||
flanges on returns, are not closed toward each other.
|
||||
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
|
||||
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
|
||||
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
|
||||
check. Under-resolved fits return an error.
|
||||
- Contact driving checks the trajectory between clear endpoints, including linked joints,
|
||||
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
|
||||
continuous collision detection; local thin walls and small details remain limitations.
|
||||
See [contact-set limits and recovery](docs/contact-sets.md).
|
||||
|
||||
## Assembly dragging
|
||||
|
||||
The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels).
|
||||
|
||||
## 3D sketch bridges
|
||||
|
||||
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
|
||||
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
|
||||
construction-plane selection of 3D wires and loft guide rails remain separate work.
|
||||
Feature dialog header chevrons collapse and restore the form without discarding selections
|
||||
or edited values, including the Appearance panel.
|
||||
## Construction on 3D paths
|
||||
|
||||
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
|
||||
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
|
||||
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
|
||||
and reference-object placement remain separate work, as do loft rails and centerlines.
|
||||
|
||||
## Design properties (#1015)
|
||||
|
||||
- Designs carry project, part number, revision, author, organization, description and licence
|
||||
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
|
||||
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
|
||||
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
|
||||
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
|
||||
Per-component properties (a part number per component) can follow with the same type.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
|
||||
to eight workers, retaining serial traversal, result order and persistent source identities.
|
||||
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
|
||||
import times; single-solid assembly parts are not scheduled across nodes. See
|
||||
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
|
||||
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
|
||||
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
|
||||
format. Generated-mesh storage measurements reduce serialization and decoding work;
|
||||
a seekable reader defers original blobs until after provisional display.
|
||||
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
|
||||
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
|
||||
and geometry records, with a separate truck adapter and bounded binary reader. Exact
|
||||
face-source certificates and operation history survive codec round trips. A provisional
|
||||
picture can be generated before original blobs are read; editing and measurement wait
|
||||
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
|
||||
the same document despite concurrent file writes. Default saves omit this cache and never force cold
|
||||
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
|
||||
Keys, resource budgets and document/revision guards reject incompatible, hostile or
|
||||
stale results. A second modelling kernel still requires its own tested adapter.
|
||||
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
|
||||
Home reads it without rebuilding the design when its local picture is missing;
|
||||
mismatched or malformed thumbnails fall back to normal generation. The image is
|
||||
kernel-independent and saving never forces cold meshing to produce it.
|
||||
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
|
||||
on a local case assembly; shape measurements and topology match the original build.
|
||||
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
|
||||
- Prepared assembly geometry survives selection and view changes while real geometry and
|
||||
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
|
||||
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
|
||||
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Successful Boolean retries report their strategy and numerical adjustments as
|
||||
feature warnings and structured inspection data. Diagnostics survive cached
|
||||
evaluation, undo/redo and cold reopening; the robustness runner counts retry
|
||||
events by strategy and reports any bounded-log truncation. Existing geometry
|
||||
acceptance checks remain in force.
|
||||
|
||||
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
|
||||
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
|
||||
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
|
||||
on such a pair, both faces are split along the line of their cylinder through the touching
|
||||
points (the solids are unchanged), the boolean is taken of those, and its history is composed
|
||||
back over the operands as given (#788). The result keeps the split lines as edges of the bore
|
||||
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
|
||||
in a 45 mm block with a tool of 80 mm) is still refused.
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
|
||||
cover stepped stock, STEP round trips and saved projections from removed pieces.
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
|
||||
|
||||
@@ -273,7 +273,7 @@ pub fn read_json(path: &str) -> Result<Value, String> {
|
||||
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
|
||||
/// A session from a design file, a STEP/3MF/STL file, a point cloud, a command script, or an oracle recipe.
|
||||
pub fn load(path: &str) -> Result<Session, String> {
|
||||
load_checked(path, solidcraft_engine::schema::ParamCheck::Off)
|
||||
}
|
||||
@@ -283,7 +283,7 @@ pub fn load(path: &str) -> Result<Session, String> {
|
||||
fn load_checked(path: &str, check: solidcraft_engine::schema::ParamCheck) -> Result<Session, String> {
|
||||
let mut s = Session::default();
|
||||
s.param_check = check;
|
||||
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
|
||||
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) || solidcraft_engine::io::is_point_cloud_path(path) {
|
||||
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
|
||||
return Ok(s);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"en": {
|
||||
"browser.edit_point_cloud_title": "Edit Point Cloud: {name}",
|
||||
"cmd.pointcloud_delete": "Delete Point Cloud",
|
||||
"cmd.pointcloud_edit": "Edit Point Cloud",
|
||||
"cmd.pointcloud_insert": "Insert Point Cloud",
|
||||
"cmd.pointcloud_list": "Point Clouds",
|
||||
"help.pointcloud.insert": "Insert a PLY, XYZ or PTS scan as a point cloud alongside the model. Use it as a reference when building a model from measured data.",
|
||||
"ui.point_cloud_auto_range": "Automatic Range",
|
||||
"ui.point_cloud_color_by": "Color By",
|
||||
"ui.point_cloud_file_colors": "File Colors",
|
||||
"ui.point_cloud_height_high": "Highest",
|
||||
"ui.point_cloud_height_low": "Lowest",
|
||||
"ui.point_cloud_no_file_colors": "The file has no colors",
|
||||
"ui.point_cloud_single_color": "Single Color",
|
||||
"ui.point_cloud_size": "Point Size",
|
||||
"ui.point_cloud_units": "File Units"
|
||||
},
|
||||
"en-GB": {
|
||||
"ui.point_cloud_color_by": "Colour By",
|
||||
"ui.point_cloud_file_colors": "File Colours",
|
||||
"ui.point_cloud_no_file_colors": "The file has no colours",
|
||||
"ui.point_cloud_single_color": "Single Colour"
|
||||
},
|
||||
"de": {
|
||||
"browser.edit_point_cloud_title": "Punktwolke bearbeiten: {name}",
|
||||
"cmd.pointcloud_delete": "Punktwolke löschen",
|
||||
"cmd.pointcloud_edit": "Punktwolke bearbeiten",
|
||||
"cmd.pointcloud_insert": "Punktwolke einfügen",
|
||||
"cmd.pointcloud_list": "Punktwolken",
|
||||
"help.pointcloud.insert": "Fügt einen PLY-, XYZ- oder PTS-Scan als Punktwolke neben dem Modell ein. Verwenden Sie ihn als Referenz beim Modellieren anhand von Messdaten.",
|
||||
"ui.point_cloud_auto_range": "Bereich automatisch",
|
||||
"ui.point_cloud_color_by": "Färben nach",
|
||||
"ui.point_cloud_file_colors": "Farben der Datei",
|
||||
"ui.point_cloud_height_high": "Oberste Höhe",
|
||||
"ui.point_cloud_height_low": "Unterste Höhe",
|
||||
"ui.point_cloud_no_file_colors": "Die Datei hat keine Farben",
|
||||
"ui.point_cloud_single_color": "Einheitliche Farbe",
|
||||
"ui.point_cloud_size": "Punktgröße",
|
||||
"ui.point_cloud_units": "Einheit der Datei"
|
||||
},
|
||||
"ja": {
|
||||
"browser.edit_point_cloud_title": "点群を編集: {name}",
|
||||
"cmd.pointcloud_delete": "点群を削除",
|
||||
"cmd.pointcloud_edit": "点群を編集",
|
||||
"cmd.pointcloud_insert": "点群を挿入",
|
||||
"cmd.pointcloud_list": "点群",
|
||||
"help.pointcloud.insert": "PLY・XYZ・PTS形式のスキャンを点群としてモデルと一緒に挿入します。計測データを参考にモデリングするときに使います。",
|
||||
"ui.point_cloud_auto_range": "範囲を自動設定",
|
||||
"ui.point_cloud_color_by": "色の付け方",
|
||||
"ui.point_cloud_file_colors": "ファイルの色",
|
||||
"ui.point_cloud_height_high": "最高の高さ",
|
||||
"ui.point_cloud_height_low": "最低の高さ",
|
||||
"ui.point_cloud_no_file_colors": "ファイルに色情報がありません",
|
||||
"ui.point_cloud_single_color": "単色",
|
||||
"ui.point_cloud_size": "点のサイズ",
|
||||
"ui.point_cloud_units": "ファイルの単位"
|
||||
},
|
||||
"ko": {
|
||||
"browser.edit_point_cloud_title": "포인트 클라우드 편집: {name}",
|
||||
"cmd.pointcloud_delete": "포인트 클라우드 삭제",
|
||||
"cmd.pointcloud_edit": "포인트 클라우드 편집",
|
||||
"cmd.pointcloud_insert": "포인트 클라우드 삽입",
|
||||
"cmd.pointcloud_list": "포인트 클라우드",
|
||||
"help.pointcloud.insert": "PLY, XYZ 또는 PTS 스캔을 모델 옆에 포인트 클라우드로 삽입합니다. 측정 데이터를 참고하여 모델링할 때 사용합니다.",
|
||||
"ui.point_cloud_auto_range": "범위 자동 설정",
|
||||
"ui.point_cloud_color_by": "색상 기준",
|
||||
"ui.point_cloud_file_colors": "파일 색상",
|
||||
"ui.point_cloud_height_high": "최고 높이",
|
||||
"ui.point_cloud_height_low": "최저 높이",
|
||||
"ui.point_cloud_no_file_colors": "파일에 색상 정보가 없습니다",
|
||||
"ui.point_cloud_single_color": "단색",
|
||||
"ui.point_cloud_size": "점 크기",
|
||||
"ui.point_cloud_units": "파일 단위"
|
||||
},
|
||||
"zh": {
|
||||
"browser.edit_point_cloud_title": "编辑点云: {name}",
|
||||
"cmd.pointcloud_delete": "删除点云",
|
||||
"cmd.pointcloud_edit": "编辑点云",
|
||||
"cmd.pointcloud_insert": "插入点云",
|
||||
"cmd.pointcloud_list": "点云",
|
||||
"help.pointcloud.insert": "将 PLY、XYZ 或 PTS 扫描作为点云插入到模型旁。根据测量数据建模时,可将其用作参考。",
|
||||
"ui.point_cloud_auto_range": "自动范围",
|
||||
"ui.point_cloud_color_by": "着色方式",
|
||||
"ui.point_cloud_file_colors": "文件颜色",
|
||||
"ui.point_cloud_height_high": "最高高度",
|
||||
"ui.point_cloud_height_low": "最低高度",
|
||||
"ui.point_cloud_no_file_colors": "文件中没有颜色",
|
||||
"ui.point_cloud_single_color": "单色",
|
||||
"ui.point_cloud_size": "点大小",
|
||||
"ui.point_cloud_units": "文件单位"
|
||||
},
|
||||
"ar": {
|
||||
"browser.edit_point_cloud_title": "تحرير سحابة النقاط: {name}",
|
||||
"cmd.pointcloud_delete": "حذف سحابة النقاط",
|
||||
"cmd.pointcloud_edit": "تحرير سحابة النقاط",
|
||||
"cmd.pointcloud_insert": "إدراج سحابة نقاط",
|
||||
"cmd.pointcloud_list": "سحب النقاط",
|
||||
"help.pointcloud.insert": "أدرج مسحًا بصيغة PLY أو XYZ أو PTS كسحابة نقاط بجانب النموذج. استخدمه كمرجع عند النمذجة انطلاقًا من بيانات القياس.",
|
||||
"ui.point_cloud_auto_range": "نطاق تلقائي",
|
||||
"ui.point_cloud_color_by": "التلوين حسب",
|
||||
"ui.point_cloud_file_colors": "ألوان الملف",
|
||||
"ui.point_cloud_height_high": "أعلى ارتفاع",
|
||||
"ui.point_cloud_height_low": "أدنى ارتفاع",
|
||||
"ui.point_cloud_no_file_colors": "لا يحتوي الملف على ألوان",
|
||||
"ui.point_cloud_single_color": "لون واحد",
|
||||
"ui.point_cloud_size": "حجم النقطة",
|
||||
"ui.point_cloud_units": "وحدات الملف"
|
||||
},
|
||||
"he": {
|
||||
"browser.edit_point_cloud_title": "עריכת ענן נקודות: {name}",
|
||||
"cmd.pointcloud_delete": "מחיקת ענן נקודות",
|
||||
"cmd.pointcloud_edit": "עריכת ענן נקודות",
|
||||
"cmd.pointcloud_insert": "הוספת ענן נקודות",
|
||||
"cmd.pointcloud_list": "ענני נקודות",
|
||||
"help.pointcloud.insert": "הוספת סריקה בפורמט PLY, XYZ או PTS כענן נקודות לצד המודל. משמש כעזר למידול על סמך נתוני מדידה.",
|
||||
"ui.point_cloud_auto_range": "טווח אוטומטי",
|
||||
"ui.point_cloud_color_by": "צביעה לפי",
|
||||
"ui.point_cloud_file_colors": "צבעי הקובץ",
|
||||
"ui.point_cloud_height_high": "הגובה המרבי",
|
||||
"ui.point_cloud_height_low": "הגובה המזערי",
|
||||
"ui.point_cloud_no_file_colors": "אין בקובץ צבעים",
|
||||
"ui.point_cloud_single_color": "צבע אחיד",
|
||||
"ui.point_cloud_size": "גודל נקודה",
|
||||
"ui.point_cloud_units": "יחידות הקובץ"
|
||||
},
|
||||
"vi": {
|
||||
"browser.edit_point_cloud_title": "Chỉnh sửa đám mây điểm: {name}",
|
||||
"cmd.pointcloud_delete": "Xóa đám mây điểm",
|
||||
"cmd.pointcloud_edit": "Chỉnh sửa đám mây điểm",
|
||||
"cmd.pointcloud_insert": "Chèn đám mây điểm",
|
||||
"cmd.pointcloud_list": "Đám mây điểm",
|
||||
"help.pointcloud.insert": "Chèn bản quét PLY, XYZ hoặc PTS dưới dạng đám mây điểm bên cạnh mô hình. Dùng làm tham chiếu khi dựng mô hình từ dữ liệu đo.",
|
||||
"ui.point_cloud_auto_range": "Phạm vi tự động",
|
||||
"ui.point_cloud_color_by": "Tô màu theo",
|
||||
"ui.point_cloud_file_colors": "Màu trong tệp",
|
||||
"ui.point_cloud_height_high": "Cao nhất",
|
||||
"ui.point_cloud_height_low": "Thấp nhất",
|
||||
"ui.point_cloud_no_file_colors": "Tệp không có màu",
|
||||
"ui.point_cloud_single_color": "Một màu",
|
||||
"ui.point_cloud_size": "Kích thước điểm",
|
||||
"ui.point_cloud_units": "Đơn vị của tệp"
|
||||
}
|
||||
}
|
||||
@@ -653,3 +653,39 @@ fn fs_img(i: IOut) -> @location(0) vec4<f32> {
|
||||
fn fs_line_all(i: LOut) -> @location(0) vec4<f32> {
|
||||
return out_color(i.c);
|
||||
}
|
||||
|
||||
// Point clouds: each point is a round dot a few pixels across, facing the screen, depth-tested
|
||||
// and written like the model's faces, so the model hides the points behind it and they hide it.
|
||||
// The colour's alpha carries the dot's size (eighths of a pixel).
|
||||
|
||||
struct POut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) c: vec4<f32>,
|
||||
@location(1) wp: vec3<f32>,
|
||||
@location(2) at: vec2<f32>,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_point(@builtin(vertex_index) vi: u32, @location(0) p: vec3<f32>, @location(1) c: vec4<f32>) -> POut {
|
||||
var xs = array<f32, 6>(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0);
|
||||
var ys = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
|
||||
let at = vec2<f32>(xs[vi], ys[vi]);
|
||||
let size = max(c.a * 255.0 / 8.0, 1.0);
|
||||
let centre = u.vp * vec4<f32>(p, 1.0);
|
||||
let half = u.screen.xy * 0.5;
|
||||
var o: POut;
|
||||
o.pos = vec4<f32>(centre.xy + at * size * 0.5 / half * centre.w, centre.z, centre.w);
|
||||
// Behind the eye: nothing.
|
||||
if (near_z(centre) < 0.0) { o.pos = vec4<f32>(0.0, 0.0, 0.0, -1.0); }
|
||||
o.c = vec4<f32>(c.rgb, 1.0);
|
||||
o.wp = p;
|
||||
// Dots under two pixels are squares: a disc that small loses its centre pixel.
|
||||
o.at = at * select(1.0, 0.0, size < 2.0);
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_point(i: POut) -> @location(0) vec4<f32> {
|
||||
if (clipped(i.wp) || dot(i.at, i.at) > 1.0) { discard; }
|
||||
return out_color(i.c);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
| assets/locales/pending/point-clouds.json | SolidCraft contributors | Original point-cloud UI strings and translations | MIT OR Apache-2.0 |
|
||||
@@ -2727,6 +2727,9 @@ pub struct Document {
|
||||
/// Reference images on planes (Insert Canvas).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub canvases: Vec<crate::canvas::Canvas>,
|
||||
/// Scanned points kept as a reference (Insert Point Cloud).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub point_clouds: Vec<crate::point_cloud::PointCloud>,
|
||||
/// Comments of feature-input parameters (by name).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub param_comments: std::collections::BTreeMap<String, String>,
|
||||
@@ -2932,6 +2935,7 @@ impl Document {
|
||||
.map(|g| g.id)
|
||||
.chain(self.timeline_groups.iter().map(|g| g.id))
|
||||
.chain(self.canvases.iter().map(|c| c.id))
|
||||
.chain(self.point_clouds.iter().map(|c| c.id))
|
||||
.chain(a.joints.iter().map(|j| j.id))
|
||||
.chain(a.contact_sets.iter().map(|c| c.id))
|
||||
.chain(a.studies.iter().map(|s| s.id))
|
||||
@@ -2979,6 +2983,7 @@ impl Document {
|
||||
appearances: Default::default(),
|
||||
favorites: Default::default(),
|
||||
canvases: Vec::new(),
|
||||
point_clouds: Vec::new(),
|
||||
param_comments: Default::default(),
|
||||
browser_groups: Vec::new(),
|
||||
timeline_groups: Vec::new(),
|
||||
|
||||
@@ -716,6 +716,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
{
|
||||
return Err(DocError::Invalid("document too large".into()));
|
||||
}
|
||||
crate::point_cloud::check_all(&d.point_clouds)?;
|
||||
let feature_ids: std::collections::BTreeSet<_> = d.features.iter().map(|feature| feature.id).collect();
|
||||
let mut group_ids = std::collections::BTreeSet::new();
|
||||
let mut grouped_features = std::collections::BTreeSet::new();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A design can hold point clouds (`point_clouds`: scanned points kept as a reference, #5151),
|
||||
//! their points and colours a binary part of the design file (base64 in a plain JSON design), with
|
||||
//! how they are coloured (`color_by`, `color`, `height_range`).
|
||||
//! Older designs need no transformation: they have none. The last release refuses a design
|
||||
//! saved with this (the newer format number).
|
||||
@@ -52,6 +52,7 @@ pub mod persistent_name;
|
||||
pub mod physical_material;
|
||||
mod piece_names;
|
||||
pub mod pipe_thread_catalog;
|
||||
pub mod point_cloud;
|
||||
mod points;
|
||||
mod profile_refs;
|
||||
pub mod progress;
|
||||
|
||||
@@ -136,6 +136,7 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
|
||||
browser_order,
|
||||
named_views,
|
||||
visibility,
|
||||
point_clouds,
|
||||
} = a;
|
||||
(Arc::ptr_eq(render, &b.render) || *render == b.render)
|
||||
&& *format == b.format
|
||||
@@ -170,6 +171,7 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
|
||||
&& *browser_order == b.browser_order
|
||||
&& *named_views == b.named_views
|
||||
&& *visibility == b.visibility
|
||||
&& *point_clouds == b.point_clouds
|
||||
}
|
||||
|
||||
/// Compare feature definitions without traversing immutable imported payloads. A replacement
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Point clouds: scanned points (a PLY or XYZ file) kept in the design as a reference to model
|
||||
//! from (#5151). They are not bodies: nothing is built from them, they are drawn as points.
|
||||
//! The points travel inside the design file (a binary part of the container, base64 in plain
|
||||
//! JSON), so it reopens without the original file. They are single precision, relative to the
|
||||
//! cloud's own `origin` (double precision), so a scan far from the design's origin keeps its
|
||||
//! detail.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::canvas::{base64_decode, base64_encode};
|
||||
use crate::{DocError, IDENTITY, Mat, Result};
|
||||
|
||||
/// Most points one cloud may hold.
|
||||
pub const MAX_POINTS: usize = 20_000_000;
|
||||
/// Largest point coordinate (mm) from the cloud's origin, and largest origin coordinate.
|
||||
pub const MAX_COORD: f64 = 1e9;
|
||||
/// Point sizes on screen (pixels).
|
||||
pub const POINT_SIZE: std::ops::RangeInclusive<f64> = 0.5..=16.0;
|
||||
/// Length units a point cloud file may be in, and millimetres per unit.
|
||||
pub const UNITS: [(&str, f64); 5] = [("mm", 1.0), ("cm", 10.0), ("m", 1000.0), ("in", 25.4), ("ft", 304.8)];
|
||||
|
||||
/// Millimetres per `unit` (one of [`UNITS`], any case).
|
||||
pub fn unit_scale(unit: &str) -> Option<f64> {
|
||||
UNITS.iter().find(|(u, _)| u.eq_ignore_ascii_case(unit)).map(|(_, k)| *k)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PointCloud {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// The file it was read from (its name only).
|
||||
#[serde(default)]
|
||||
pub file: String,
|
||||
/// Where the points' own frame sits in the cloud's placement (mm).
|
||||
pub origin: Vec3,
|
||||
/// The points from `origin` (mm).
|
||||
pub points: Points,
|
||||
/// The file's colour of each point, when it has them.
|
||||
#[serde(default, skip_serializing_if = "Colors::is_empty")]
|
||||
pub colors: Colors,
|
||||
/// Points the file had (more than `points` when the cloud was thinned as it came in).
|
||||
#[serde(default)]
|
||||
pub source_points: u64,
|
||||
/// The file's length unit (one of [`UNITS`]): the points were scaled from it to millimetres.
|
||||
#[serde(default = "mm")]
|
||||
pub units: String,
|
||||
/// Where the cloud sits in the design: a rigid move of its points.
|
||||
#[serde(default = "identity", skip_serializing_if = "is_identity")]
|
||||
pub transform: Mat,
|
||||
/// Size of a point on screen (pixels).
|
||||
#[serde(default = "two")]
|
||||
pub point_size: f64,
|
||||
/// How the points are coloured.
|
||||
#[serde(default, skip_serializing_if = "ColorBy::is_file")]
|
||||
pub color_by: ColorBy,
|
||||
/// The colour of every point with [`ColorBy::Single`] (straight sRGB, 0..1; `None`: [`PLAIN`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<[f32; 3]>,
|
||||
/// The heights (design Z, mm) the [`ColorBy::Height`] ramp runs between, lowest first;
|
||||
/// `None`: worked out from the points ([`PointCloud::auto_height_range`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub height_range: Option<[f64; 2]>,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// How a cloud's points are coloured.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ColorBy {
|
||||
/// The file's colour of each point ([`PLAIN`] when it has none).
|
||||
#[default]
|
||||
File,
|
||||
/// One colour for every point ([`PointCloud::color`]).
|
||||
Single,
|
||||
/// By height in the design: blue low, through cyan, green and yellow, to red high.
|
||||
Height,
|
||||
}
|
||||
|
||||
impl ColorBy {
|
||||
fn is_file(&self) -> bool {
|
||||
*self == ColorBy::File
|
||||
}
|
||||
/// From its name (`file`, `single`, `height`).
|
||||
pub fn parse(s: &str) -> Option<ColorBy> {
|
||||
match s {
|
||||
"file" => Some(ColorBy::File),
|
||||
"single" => Some(ColorBy::Single),
|
||||
"height" => Some(ColorBy::Height),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ColorBy::File => "file",
|
||||
ColorBy::Single => "single",
|
||||
ColorBy::Height => "height",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour of points that have no other (straight sRGB, 0..1).
|
||||
pub const PLAIN: [f32; 3] = [0.59, 0.63, 0.69];
|
||||
/// Points sampled for the automatic height range.
|
||||
const HEIGHT_SAMPLES: usize = 200_000;
|
||||
/// The share of points left below and above the automatic height range, so a few stray points
|
||||
/// don't flatten the ramp.
|
||||
const HEIGHT_TAIL: f64 = 0.02;
|
||||
|
||||
/// A colour of the height ramp at `t` (0: low, 1: high): blue, cyan, green, yellow, red.
|
||||
pub fn height_ramp(t: f64) -> [u8; 3] {
|
||||
const STOPS: [[f64; 3]; 5] = [[40.0, 70.0, 200.0], [30.0, 170.0, 230.0], [60.0, 200.0, 90.0], [240.0, 220.0, 60.0], [230.0, 70.0, 50.0]];
|
||||
let k = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 } * (STOPS.len() - 1) as f64;
|
||||
let i = (k as usize).min(STOPS.len() - 2);
|
||||
let f = k - i as f64;
|
||||
let (a, b) = (STOPS[i], STOPS[i + 1]);
|
||||
std::array::from_fn(|c| (a[c] * (1.0 - f) + b[c] * f).round().clamp(0.0, 255.0) as u8)
|
||||
}
|
||||
|
||||
/// A colour channel (0..1) as a byte.
|
||||
pub fn byte(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
fn identity() -> Mat {
|
||||
IDENTITY
|
||||
}
|
||||
fn is_identity(m: &Mat) -> bool {
|
||||
*m == IDENTITY
|
||||
}
|
||||
fn mm() -> String {
|
||||
"mm".into()
|
||||
}
|
||||
fn two() -> f64 {
|
||||
2.0
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl PointCloud {
|
||||
pub fn len(&self) -> usize {
|
||||
self.points.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.points.len() == 0
|
||||
}
|
||||
/// The `i`th point in the design (mm).
|
||||
pub fn world_point(&self, i: usize) -> Option<Vec3> {
|
||||
let p = self.points.get(i)?;
|
||||
let local = self.origin + Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]));
|
||||
Some(crate::apply_point(&self.transform, local))
|
||||
}
|
||||
/// The corners of the box around the points, in the cloud's placement (mm), or `None` for
|
||||
/// an empty cloud.
|
||||
pub fn local_bounds(&self) -> Option<(Vec3, Vec3)> {
|
||||
let (lo, hi) = self.points.bounds()?;
|
||||
let v = |p: [f32; 3]| self.origin + Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]));
|
||||
Some((v(lo), v(hi)))
|
||||
}
|
||||
/// The design Z (mm) of a point from the cloud's origin.
|
||||
fn world_z(&self, p: [f32; 3]) -> f64 {
|
||||
let m = &self.transform;
|
||||
let l = [self.origin.x + f64::from(p[0]), self.origin.y + f64::from(p[1]), self.origin.z + f64::from(p[2])];
|
||||
m[0][2] * l[0] + m[1][2] * l[1] + m[2][2] * l[2] + m[3][2]
|
||||
}
|
||||
/// The heights (design Z, mm) holding all but the lowest and highest 2 % of the points (an
|
||||
/// even sample of them), or `None` for an empty cloud. A flat cloud gets a range 1 mm high.
|
||||
pub fn auto_height_range(&self) -> Option<[f64; 2]> {
|
||||
let n = self.points.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let step = n.div_ceil(HEIGHT_SAMPLES).max(1);
|
||||
let mut z: Vec<f64> = (0..n).step_by(step).filter_map(|i| self.points.get(i)).map(|p| self.world_z(p)).collect();
|
||||
z.sort_by(f64::total_cmp);
|
||||
let last = z.len().checked_sub(1)?;
|
||||
let at = |q: f64| z.get((last as f64 * q).round() as usize).copied();
|
||||
let (lo, hi) = (at(HEIGHT_TAIL)?, at(1.0 - HEIGHT_TAIL)?);
|
||||
Some(if hi - lo > 1e-9 { [lo, hi] } else { [lo - 0.5, lo + 0.5] })
|
||||
}
|
||||
/// The heights the height ramp runs between now: the cloud's own, else the automatic ones.
|
||||
pub fn height_range_now(&self) -> Option<[f64; 2]> {
|
||||
self.height_range.or_else(|| self.auto_height_range())
|
||||
}
|
||||
/// The colour of each point (by index) as the cloud is shown.
|
||||
pub fn shade(&self) -> impl Fn(usize) -> [u8; 3] + '_ {
|
||||
let single = self.color.unwrap_or(PLAIN).map(byte);
|
||||
let plain = PLAIN.map(byte);
|
||||
let range = if self.color_by == ColorBy::Height { self.height_range_now() } else { None };
|
||||
move |i| match self.color_by {
|
||||
ColorBy::File => self.colors.get(i).unwrap_or(plain),
|
||||
ColorBy::Single => single,
|
||||
ColorBy::Height => match (range, self.points.get(i)) {
|
||||
(Some([lo, hi]), Some(p)) => height_ramp((self.world_z(p) - lo) / (hi - lo)),
|
||||
_ => plain,
|
||||
},
|
||||
}
|
||||
}
|
||||
/// The corners of the box around the points, placed in the design (mm).
|
||||
pub fn world_corners(&self) -> Option<[Vec3; 8]> {
|
||||
let (lo, hi) = self.local_bounds()?;
|
||||
Some(std::array::from_fn(|i| {
|
||||
let q = Vec3::new(if i & 1 == 0 { lo.x } else { hi.x }, if i & 2 == 0 { lo.y } else { hi.y }, if i & 4 == 0 { lo.z } else { hi.z });
|
||||
crate::apply_point(&self.transform, q)
|
||||
}))
|
||||
}
|
||||
/// The cloud is whole: as many colours as points, finite coordinates within range, a size
|
||||
/// on screen in range and a rigid placement.
|
||||
pub fn check(&self) -> Result<()> {
|
||||
let bad = |what: &str| Err(DocError::Invalid(format!("point cloud `{}`: {what}", self.name)));
|
||||
if self.points.len() > MAX_POINTS {
|
||||
return bad("too many points");
|
||||
}
|
||||
if !self.colors.is_empty() && self.colors.len() != self.points.len() {
|
||||
return bad("its colours do not match its points");
|
||||
}
|
||||
if ![self.origin.x, self.origin.y, self.origin.z].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) {
|
||||
return bad("its origin is out of range");
|
||||
}
|
||||
if self.points.values().iter().any(|v| !v.is_finite() || f64::from(v.abs()) > MAX_COORD) {
|
||||
return bad("a point is out of range");
|
||||
}
|
||||
if unit_scale(&self.units).is_none() {
|
||||
return bad("its units are not mm, cm, m, in or ft");
|
||||
}
|
||||
if !(self.point_size.is_finite() && POINT_SIZE.contains(&self.point_size)) {
|
||||
return bad("its point size is out of range");
|
||||
}
|
||||
if self.color.is_some_and(|c| c.iter().any(|v| !(0.0..=1.0).contains(v))) {
|
||||
return bad("its colour is out of range");
|
||||
}
|
||||
if self.height_range.is_some_and(|[lo, hi]| !(lo.is_finite() && hi.is_finite() && lo < hi && lo.abs() <= MAX_COORD && hi.abs() <= MAX_COORD))
|
||||
{
|
||||
return bad("its height range is not two heights, lowest first");
|
||||
}
|
||||
if !rigid(&self.transform) {
|
||||
return bad("its placement is not a rigid move");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A rotation and a translation within range (no scale, shear or mirror).
|
||||
pub fn rigid(m: &Mat) -> bool {
|
||||
let column = |i: usize| Vec3::new(m[i][0], m[i][1], m[i][2]);
|
||||
let (x, y, z) = (column(0), column(1), column(2));
|
||||
m.iter().flatten().all(|v| v.is_finite())
|
||||
&& m[3][..3].iter().all(|v| v.abs() <= MAX_COORD)
|
||||
&& [x, y, z].iter().all(|axis| (axis.dot(*axis) - 1.0).abs() < 1e-6)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6
|
||||
&& x.cross(y).dot(z) > 0.0
|
||||
&& [m[0][3], m[1][3], m[2][3]].iter().all(|v| v.abs() < 1e-9)
|
||||
&& (m[3][3] - 1.0).abs() < 1e-9
|
||||
}
|
||||
|
||||
/// Corners of the box around some points.
|
||||
type Corners = Option<([f32; 3], [f32; 3])>;
|
||||
|
||||
/// x, y, z per point (single precision, little-endian when stored). Shared, so undo states and
|
||||
/// copies of the design don't copy the points; the box around them is worked out once.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Points {
|
||||
values: Arc<[f32]>,
|
||||
bounds: Arc<std::sync::OnceLock<Corners>>,
|
||||
}
|
||||
|
||||
impl Points {
|
||||
pub fn new(values: Arc<[f32]>) -> Points {
|
||||
Points { values, bounds: Default::default() }
|
||||
}
|
||||
/// x, y, z per point.
|
||||
pub fn values(&self) -> &Arc<[f32]> {
|
||||
&self.values
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.values.len() / 3
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values.len() < 3
|
||||
}
|
||||
pub fn get(&self, i: usize) -> Option<[f32; 3]> {
|
||||
let s = self.values.get(i.checked_mul(3)?..i.checked_mul(3)?.checked_add(3)?)?;
|
||||
Some([*s.first()?, *s.get(1)?, *s.get(2)?])
|
||||
}
|
||||
pub fn iter(&self) -> impl Iterator<Item = [f32; 3]> + '_ {
|
||||
self.values.as_chunks::<3>().0.iter().copied()
|
||||
}
|
||||
/// The lowest and highest x, y and z, or `None` without points.
|
||||
pub fn bounds(&self) -> Corners {
|
||||
*self.bounds.get_or_init(|| {
|
||||
let mut it = self.iter();
|
||||
let first = it.next()?;
|
||||
Some(it.fold((first, first), |(mut lo, mut hi), p| {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
(lo, hi)
|
||||
}))
|
||||
})
|
||||
}
|
||||
/// The points as stored: little-endian f32, x, y, z per point.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.values.len() * 4);
|
||||
for v in self.values.iter() {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The points from stored bytes; `None` unless whole points of finite coordinates.
|
||||
pub fn from_bytes(b: &[u8]) -> Option<Points> {
|
||||
if !b.len().is_multiple_of(12) || b.len() / 12 > MAX_POINTS {
|
||||
return None;
|
||||
}
|
||||
let v: Vec<f32> = b.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes(*c)).collect();
|
||||
v.iter().all(|x| x.is_finite()).then(|| Points::new(v.into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The same points: the same shared storage, or equal values.
|
||||
impl PartialEq for Points {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
Arc::ptr_eq(&self.values, &other.values) || self.values == other.values
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Points {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&base64_encode(&self.to_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Points {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
if s.len() > (MAX_POINTS * 12).div_ceil(3) * 4 + 16 {
|
||||
return Err(serde::de::Error::custom("too many points"));
|
||||
}
|
||||
let b = base64_decode(&s).ok_or_else(|| serde::de::Error::custom("points are not base64"))?;
|
||||
Points::from_bytes(&b).ok_or_else(|| serde::de::Error::custom("points are not whole, finite x, y, z"))
|
||||
}
|
||||
}
|
||||
|
||||
/// r, g, b per point (bytes).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Colors(pub Arc<[u8]>);
|
||||
|
||||
impl Colors {
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len() / 3
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
pub fn get(&self, i: usize) -> Option<[u8; 3]> {
|
||||
let s = self.0.get(i.checked_mul(3)?..i.checked_mul(3)?.checked_add(3)?)?;
|
||||
Some([*s.first()?, *s.get(1)?, *s.get(2)?])
|
||||
}
|
||||
pub fn from_bytes(b: &[u8]) -> Option<Colors> {
|
||||
(b.len().is_multiple_of(3) && b.len() / 3 <= MAX_POINTS).then(|| Colors(b.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Colors {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
Arc::ptr_eq(&self.0, &other.0) || self.0 == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Colors {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&base64_encode(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Colors {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let s = String::deserialize(d)?;
|
||||
if s.len() > (MAX_POINTS * 3).div_ceil(3) * 4 + 16 {
|
||||
return Err(serde::de::Error::custom("too many colours"));
|
||||
}
|
||||
let b = base64_decode(&s).ok_or_else(|| serde::de::Error::custom("colours are not base64"))?;
|
||||
Colors::from_bytes(&b).ok_or_else(|| serde::de::Error::custom("colours are not whole r, g, b"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cloud of a design is whole ([`PointCloud::check`]) and has its own id.
|
||||
pub fn check_all(clouds: &[PointCloud]) -> Result<()> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for c in clouds {
|
||||
c.check()?;
|
||||
if !ids.insert(c.id) {
|
||||
return Err(DocError::Invalid(format!("two point clouds have the id {}", c.id)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cloud(points: Vec<f32>, colors: Vec<u8>) -> PointCloud {
|
||||
PointCloud {
|
||||
id: 7,
|
||||
name: "Scan".into(),
|
||||
file: "scan.ply".into(),
|
||||
origin: Vec3::new(1000.0, 0.0, 0.0),
|
||||
points: Points::new(points.into()),
|
||||
colors: Colors(colors.into()),
|
||||
source_points: 2,
|
||||
units: "m".into(),
|
||||
transform: IDENTITY,
|
||||
point_size: 2.0,
|
||||
color_by: ColorBy::File,
|
||||
color: None,
|
||||
height_range: None,
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_round_trip_through_json_and_stay_shared() {
|
||||
let c = cloud(vec![0.0, 1.0, 2.0, -3.5, 4.25, 1e6], vec![255, 0, 10, 1, 2, 3]);
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let back: PointCloud = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, c);
|
||||
assert_eq!(back.points.get(1), Some([-3.5, 4.25, 1e6]));
|
||||
assert_eq!(back.colors.get(0), Some([255, 0, 10]));
|
||||
assert_eq!(back.world_point(0), Some(Vec3::new(1000.0, 1.0, 2.0)));
|
||||
assert_eq!(back.points.get(2), None);
|
||||
let copy = c.clone();
|
||||
assert!(Arc::ptr_eq(copy.points.values(), c.points.values()), "a copy shares the points");
|
||||
let (lo, hi) = c.local_bounds().unwrap();
|
||||
assert_eq!((lo, hi), (Vec3::new(996.5, 1.0, 2.0), Vec3::new(1000.0, 4.25, 1e6)));
|
||||
check_all(&[c]).unwrap();
|
||||
assert_eq!((unit_scale("M"), unit_scale("yd")), (Some(1000.0), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_clouds_are_refused() {
|
||||
// Not whole points, not finite, not base64.
|
||||
for points in ["AAAA", "AADAfwAAwH8AAMB/", "!!!!"] {
|
||||
let json = serde_json::to_string(&cloud(vec![0.0; 3], Vec::new())).unwrap().replace("\"AAAAAAAAAAAAAAAA\"", &format!("\"{points}\""));
|
||||
assert!(serde_json::from_str::<PointCloud>(&json).is_err(), "{points}");
|
||||
}
|
||||
assert!(cloud(vec![0.0; 6], vec![1, 2, 3]).check().is_err(), "one colour for two points");
|
||||
let mut c = cloud(vec![0.0; 3], Vec::new());
|
||||
c.point_size = 100.0;
|
||||
assert!(c.check().is_err());
|
||||
let mut c = cloud(vec![0.0; 3], Vec::new());
|
||||
c.transform[0][0] = 2.0;
|
||||
assert!(c.check().is_err(), "a scale is not a placement");
|
||||
let c = cloud(vec![0.0; 3], Vec::new());
|
||||
assert!(check_all(&[c.clone(), c]).is_err(), "two clouds with one id");
|
||||
for range in [[5.0, 5.0], [9.0, 1.0], [f64::NAN, 1.0], [0.0, 1e12]] {
|
||||
let mut c = cloud(vec![0.0; 3], Vec::new());
|
||||
c.height_range = Some(range);
|
||||
assert!(c.check().is_err(), "{range:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_are_coloured_by_the_file_one_colour_or_their_height() {
|
||||
// Heights 0..=100 mm, and a stray point far above the rest.
|
||||
let mut values: Vec<f32> = (0..=100).flat_map(|z| [0.0, 0.0, z as f32]).collect();
|
||||
values.extend([0.0, 0.0, 1000.0]);
|
||||
let colors: Vec<u8> = (0..102).flat_map(|i| [i as u8, 7, 9]).collect();
|
||||
let mut c = cloud(values, colors);
|
||||
c.origin = Vec3::ZERO;
|
||||
assert_eq!(c.shade()(3), [3, 7, 9], "the file's colours");
|
||||
c.colors = Colors::default();
|
||||
assert_eq!(c.shade()(3), PLAIN.map(byte), "none: plain");
|
||||
c.color_by = ColorBy::Single;
|
||||
c.color = Some([1.0, 0.5, 0.0]);
|
||||
assert_eq!(c.shade()(50), [255, 128, 0]);
|
||||
// The stray point doesn't stretch the automatic range.
|
||||
c.color_by = ColorBy::Height;
|
||||
// 102 points: the 2nd and the 99th (of 0…101) once sorted.
|
||||
assert_eq!(c.auto_height_range(), Some([2.0, 99.0]));
|
||||
{
|
||||
let shade = c.shade();
|
||||
assert_eq!((shade(0), shade(100), shade(101)), (height_ramp(0.0), height_ramp(1.0), height_ramp(1.0)));
|
||||
assert_eq!(shade(50), height_ramp(48.0 / 97.0));
|
||||
}
|
||||
// A range of its own; moved up 100 mm, everything is above it.
|
||||
c.height_range = Some([0.0, 50.0]);
|
||||
assert_eq!(c.shade()(25), height_ramp(0.5));
|
||||
c.transform[3][2] = 100.0;
|
||||
assert_eq!(c.shade()(0), height_ramp(1.0));
|
||||
assert_eq!((height_ramp(0.0), height_ramp(1.0), height_ramp(f64::NAN)), ([40, 70, 200], [230, 70, 50], [40, 70, 200]));
|
||||
let back: PointCloud = serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap();
|
||||
assert_eq!(back, c);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
or joints?: [joint id or name] (their glyphs; they still constrain and move parts); \
|
||||
or features?: [feature id or name] (the bodies they made; path?: [occurrence ids] for only that placement's); \
|
||||
or folder?: bodies|sketches|construction|origin|joints|all (all of it; showing also shows its browser folders and groups, and `all` takes in the occurrence hides too); \
|
||||
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases] (the folder or group itself: its items keep their own state); \
|
||||
or tree_folders?: [browser folder or group key: c0/bodies, o:4/sketches, c0/g7, c0/joints, c0/canvases, c0/point_clouds] (the folder or group itself: its items keep their own state); \
|
||||
or paths?: [[occurrence ids]] (exact placements and descendants), isolate?: bool (requires paths and visible:true). Saved with the design and undone like any other change (the camera is not)",
|
||||
),
|
||||
CommandSpec::new("browser.expand_all", "Expand All", expand_all).icon("folder").noundo().params(
|
||||
@@ -258,8 +258,9 @@ fn tree_folder_key(k: &str) -> bool {
|
||||
let Some((scope, last)) = k.rsplit_once('/') else { return false };
|
||||
let scope_ok =
|
||||
scope == "c0" || scope.strip_prefix("o:").is_some_and(|ids| ids.split('/').count() <= 64 && ids.split('/').all(|i| i.parse::<u64>().is_ok()));
|
||||
let last_ok =
|
||||
FOLDERS.contains(&last) || ["joints", "canvases"].contains(&last) || last.strip_prefix('g').is_some_and(|g| g.parse::<u64>().is_ok());
|
||||
let last_ok = FOLDERS.contains(&last)
|
||||
|| ["joints", "canvases", "point_clouds"].contains(&last)
|
||||
|| last.strip_prefix('g').is_some_and(|g| g.parse::<u64>().is_ok());
|
||||
k.len() <= 2048 && scope_ok && last_ok
|
||||
}
|
||||
|
||||
@@ -556,6 +557,9 @@ fn collect_component_keys(s: &Session, prefix: &str, component: u64, root_extras
|
||||
if !s.doc.canvases.is_empty() {
|
||||
keys.open.insert("canvases".into());
|
||||
}
|
||||
if !s.doc.point_clouds.is_empty() {
|
||||
keys.open.insert("point_clouds".into());
|
||||
}
|
||||
if !s.doc.assembly.joints.is_empty() {
|
||||
keys.open.insert("c0/joints".into());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Delete for everything that can be selected, in one undo step: sketch entities (while
|
||||
//! sketching), timeline features and sketches (with the features that depend on them),
|
||||
//! construction planes and axes, bodies (a Remove feature from here on in the timeline), occurrences and
|
||||
//! components, canvases and joints. `dry_run` reports what would go and what would fail without
|
||||
//! components, canvases, point clouds and joints. `dry_run` reports what would go and what would fail without
|
||||
//! changing anything, so the UI can ask first.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -13,7 +13,7 @@ use crate::params::{bad, bool_};
|
||||
use crate::{EngineError, Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", delete).icon("delete").params(
|
||||
"items?: [selection items]; components?, occurrences?, canvases?, joints?: [ids or names]; dry_run?: bool → deleted, deleted_joints, would_fail, skipped (one undo step)",
|
||||
"items?: [selection items]; components?, occurrences?, canvases?, point_clouds?, joints?: [ids or names]; dry_run?: bool → deleted, deleted_joints, would_fail, skipped (one undo step)",
|
||||
)];
|
||||
|
||||
/// Run another command's body inside this one (so the whole delete is one undo step).
|
||||
@@ -212,6 +212,10 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sub(s, "canvas.delete", json!({ "canvas": k }))?;
|
||||
done = true;
|
||||
}
|
||||
for k in keys(p, "point_clouds") {
|
||||
sub(s, "pointcloud.delete", json!({ "cloud": k }))?;
|
||||
done = true;
|
||||
}
|
||||
if !done {
|
||||
return Err(bad(cmd, skipped.first().cloned().unwrap_or_else(|| "nothing to delete".into())));
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
|
||||
.params("path: .sc design (or a .solidcraft design from an earlier release), .dxf/.svg drawing (opens as a new, unsaved design with an XY sketch, using sketch.insert_dxf/sketch.insert_svg units and defaults), or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make), or a .ply/.xyz/.pts point cloud (opens as a new design holding it, as pointcloud.insert makes; its `units` and `max_points` as there); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step); read_only?: bool (a design: saving to its own file is refused, file.save_as keeps the changes elsewhere) → open_elsewhere: {host, user, app, opened} when another SolidCraft has the design open (its lock file is fresh)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .3mf, .stl, .obj, .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept)"),
|
||||
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept)"),
|
||||
CommandSpec::new("file.save", "Save", save)
|
||||
.icon("save")
|
||||
.key("Ctrl+S")
|
||||
@@ -240,8 +240,8 @@ fn mesh_up(p: &Value, path: &str, cmd: &str) -> Result<Option<bool>> {
|
||||
fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.insert_mesh";
|
||||
let path = path_arg(p, cmd)?;
|
||||
if !solidcraft_io::is_mesh_path(path) {
|
||||
return Err(bad(cmd, "only 3MF, STL, OBJ and glTF files (.3mf, .stl, .obj, .glb, .gltf) can be inserted as meshes"));
|
||||
if !solidcraft_io::is_mesh_path(path) && !solidcraft_io::is_ply_path(path) {
|
||||
return Err(bad(cmd, "only 3MF, STL, OBJ, PLY and glTF files (.3mf, .stl, .obj, .ply, .glb, .gltf) can be inserted as meshes"));
|
||||
}
|
||||
let up = mesh_up(p, path, cmd)?;
|
||||
add_mesh(s, path, str_(p, "name"), up)
|
||||
@@ -290,6 +290,24 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
out["errors"] = json!(s.model.results.iter().filter(|r| r.error.is_some()).count());
|
||||
return Ok(out);
|
||||
}
|
||||
if solidcraft_io::is_point_cloud_path(path) {
|
||||
// A new, unsaved design holding the points.
|
||||
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
|
||||
let mut fresh = Session::new(Document::new(&stem));
|
||||
let mut q = serde_json::Map::new();
|
||||
q.insert("path".into(), json!(path));
|
||||
for k in ["units", "max_points"] {
|
||||
if let Some(v) = p.get(k) {
|
||||
q.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let r = fresh.execute("pointcloud.insert", &Value::Object(q))?;
|
||||
fresh.undo.clear();
|
||||
s.replace_design(fresh);
|
||||
return Ok(
|
||||
json!({"path": path, "features": 0, "errors": 0, "cloud": r["cloud"], "points": r["points"], "source_points": r["source_points"], "bounds": r["bounds"], "warnings": r["warnings"]}),
|
||||
);
|
||||
}
|
||||
if solidcraft_io::is_step_path(path) || solidcraft_io::is_mesh_path(path) {
|
||||
// A new, unsaved design holding the file's bodies.
|
||||
let stem = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled".into());
|
||||
|
||||
@@ -64,6 +64,7 @@ mod mesh;
|
||||
mod metadata;
|
||||
pub(crate) mod motion;
|
||||
mod parts;
|
||||
mod point_cloud;
|
||||
mod script;
|
||||
mod section;
|
||||
pub(crate) use measure_sel::{measure_items, shape_of};
|
||||
@@ -287,6 +288,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch_ends::COMMANDS.iter());
|
||||
v.extend(sketch_canvas::COMMANDS.iter());
|
||||
v.extend(canvas_sketch::COMMANDS.iter());
|
||||
v.extend(point_cloud::COMMANDS.iter());
|
||||
v.extend(sketch_3d::COMMANDS.iter());
|
||||
v.extend(surface_analysis::COMMANDS.iter());
|
||||
v.extend(surface_boundary::COMMANDS.iter());
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Point clouds (#5151): scanned points (PLY, XYZ or PTS files) kept in the design as a reference
|
||||
//! to model from. They are drawn as points and never become bodies.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::Mat;
|
||||
use solidcraft_doc::point_cloud::{ColorBy, Colors, MAX_COORD, MAX_POINTS, POINT_SIZE, PointCloud, Points, UNITS, unit_scale};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, str_};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("pointcloud.insert", "Insert Point Cloud", insert)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .ply, .xyz or .pts file (its points join the design as a point cloud: drawn as points, never a body; a PLY's red, green and blue are its colours) → cloud, name, points, source_points, bounds, warnings; name?; units?: mm|cm|m|in|ft (the file's length unit, default mm); max_points?: most points kept (default 5000000, at most 20000000; a larger file is thinned evenly); center?: bool (move the cloud so the middle of its base sits at the origin); point_size?: pixels (0.5…16, default 2); color_by?: file|single|height (the file's colours, the default; one colour; or a ramp by height, blue low to red high); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); height_range?: [low, high] design Z in mm for the height ramp, or null to work it out from the points (all but the lowest and highest 2 %)"),
|
||||
CommandSpec::new("pointcloud.edit", "Edit Point Cloud", edit)
|
||||
.icon("edit")
|
||||
.params("cloud: id or name; name?; visible?; point_size?: pixels (0.5…16); color_by?: file|single|height (the file's colours, the default; one colour; or a ramp by height, blue low to red high); color?: [r, g, b] 0…1 (the single colour; given alone it sets color_by single, null sets color_by file); height_range?: [low, high] design Z in mm for the height ramp, or null to work it out from the points (all but the lowest and highest 2 %); units?: mm|cm|m|in|ft (the file's length unit: the points are scaled from the unit they came in as); matrix?: [12 or 16 numbers] (where the cloud sits: a rigid row-major 3x4 or 4x4 matrix, as occurrence.move takes it); tolerance? → cloud"),
|
||||
CommandSpec::new("pointcloud.delete", "Delete Point Cloud", delete).icon("delete").params("cloud: id or name → deleted"),
|
||||
CommandSpec::new("pointcloud.list", "Point Clouds", list)
|
||||
.icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ clouds: [{cloud, name, file, points, source_points, units, visible, point_size, color_by, color, colored, height_range (the ramp's now, its own or worked out), height_auto, bounds: [[x,y,z], [x,y,z]] in the design, matrix}]"),
|
||||
];
|
||||
|
||||
fn units_of(p: &Value, cmd: &str) -> Result<Option<(&'static str, f64)>> {
|
||||
match p.get("units") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(u)) => UNITS
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(u))
|
||||
.map(|(name, k)| Some((*name, *k)))
|
||||
.ok_or_else(|| bad(cmd, format!("`units` must be one of mm, cm, m, in, ft (not `{u}`)"))),
|
||||
Some(_) => Err(bad(cmd, "`units` must be one of mm, cm, m, in, ft")),
|
||||
}
|
||||
}
|
||||
|
||||
fn point_size(p: &Value, cmd: &str) -> Result<Option<f64>> {
|
||||
match p.get("point_size") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|s| s.is_finite() && POINT_SIZE.contains(s))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("`point_size` must be a number of pixels from {} to {}", POINT_SIZE.start(), POINT_SIZE.end()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `color`: absent (`None`), null for the file's colours (`Some(None)`), or [r, g, b] 0…1.
|
||||
fn color(p: &Value, cmd: &str) -> Result<Option<Option<[f32; 3]>>> {
|
||||
match p.get("color") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::Array(a)) if a.len() == 3 => {
|
||||
let c: Option<Vec<f32>> = a.iter().map(|v| v.as_f64().filter(|x| (0.0..=1.0).contains(x)).map(|x| x as f32)).collect();
|
||||
match c.as_deref() {
|
||||
Some(&[r, g, b]) => Ok(Some(Some([r, g, b]))),
|
||||
_ => Err(bad(cmd, "`color` must be [r, g, b] with each from 0 to 1")),
|
||||
}
|
||||
}
|
||||
Some(_) => Err(bad(cmd, "`color` must be [r, g, b] with each from 0 to 1, or null")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `color_by`: absent (`None`), or file, single or height.
|
||||
fn color_by(p: &Value, cmd: &str) -> Result<Option<ColorBy>> {
|
||||
match p.get("color_by") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_str().and_then(ColorBy::parse).map(Some).ok_or_else(|| bad(cmd, "`color_by` must be file, single or height")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `height_range`: absent (`None`), null for the automatic one (`Some(None)`), or [low, high].
|
||||
fn height_range(p: &Value, cmd: &str) -> Result<Option<Option<[f64; 2]>>> {
|
||||
let refuse = || bad(cmd, "`height_range` must be [low, high] heights in mm, lowest first, or null");
|
||||
match p.get("height_range") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::Array(a)) => match a.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>().as_deref() {
|
||||
Some(&[lo, hi]) if lo.is_finite() && hi.is_finite() && lo < hi && lo.abs() <= MAX_COORD && hi.abs() <= MAX_COORD => {
|
||||
Ok(Some(Some([lo, hi])))
|
||||
}
|
||||
_ => Err(refuse()),
|
||||
},
|
||||
Some(_) => Err(refuse()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply `color_by`, `color` and `height_range`: a colour given alone means one colour, and
|
||||
/// `color: null` the file's.
|
||||
fn set_colors(c: &mut PointCloud, by: Option<ColorBy>, color: Option<Option<[f32; 3]>>, range: Option<Option<[f64; 2]>>) {
|
||||
if let Some(k) = color {
|
||||
c.color = k.or(c.color);
|
||||
c.color_by = if k.is_some() { ColorBy::Single } else { ColorBy::File };
|
||||
}
|
||||
if let Some(b) = by {
|
||||
c.color_by = b;
|
||||
}
|
||||
if let Some(r) = range {
|
||||
c.height_range = r;
|
||||
}
|
||||
}
|
||||
|
||||
/// The cloud's placement as `matrix` takes it: 12 numbers, row by row.
|
||||
fn matrix_rows(m: &Mat) -> Vec<f64> {
|
||||
(0..3).flat_map(|row| (0..4).map(move |col| m[col][row])).collect()
|
||||
}
|
||||
|
||||
fn bounds(c: &PointCloud) -> Value {
|
||||
let Some(corners) = c.world_corners() else { return Value::Null };
|
||||
// The box around the corners in the design.
|
||||
let mut a = [f64::INFINITY; 3];
|
||||
let mut b = [f64::NEG_INFINITY; 3];
|
||||
for w in corners {
|
||||
for (k, v) in [w.x, w.y, w.z].into_iter().enumerate() {
|
||||
a[k] = a[k].min(v);
|
||||
b[k] = b[k].max(v);
|
||||
}
|
||||
}
|
||||
json!([a, b])
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "pointcloud.insert";
|
||||
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?;
|
||||
if !solidcraft_io::is_point_cloud_path(path) {
|
||||
return Err(bad(cmd, "only PLY, XYZ and PTS files (.ply, .xyz, .pts) can be inserted as point clouds"));
|
||||
}
|
||||
let (units, scale) = units_of(p, cmd)?.unwrap_or(("mm", 1.0));
|
||||
let max_points = match p.get("max_points") {
|
||||
None | Some(Value::Null) => solidcraft_io::DEFAULT_MAX_POINTS,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| (1..=MAX_POINTS as u64).contains(n))
|
||||
.map(|n| n as usize)
|
||||
.ok_or_else(|| bad(cmd, format!("`max_points` must be a whole number from 1 to {MAX_POINTS}")))?,
|
||||
};
|
||||
let size = point_size(p, cmd)?.unwrap_or(2.0);
|
||||
let colors = (color_by(p, cmd)?, color(p, cmd)?, height_range(p, cmd)?);
|
||||
let center = bool_(p, "center").unwrap_or(false);
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_POINT_CLOUD_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let read = solidcraft_io::read_point_cloud(&bytes, path, scale, max_points).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mut warnings = read.warnings;
|
||||
if solidcraft_io::is_ply_path(path)
|
||||
&& let Ok((_, faces)) = solidcraft_io::ply_counts(&bytes)
|
||||
&& faces > 0
|
||||
{
|
||||
warnings.push(format!("the file also has {faces} faces: Insert Mesh (file.insert_mesh) reads it as a mesh"));
|
||||
}
|
||||
drop(bytes);
|
||||
let file = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let stem = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().trim().to_string()).filter(|n| !n.is_empty());
|
||||
let taken = |n: &str| s.doc.point_clouds.iter().any(|c| c.name == n);
|
||||
let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or_else(|| {
|
||||
let base = stem.unwrap_or_else(|| "Point Cloud".into());
|
||||
if !taken(&base) { base } else { (2..=s.doc.point_clouds.len() + 2).map(|k| format!("{base} ({k})")).find(|n| !taken(n)).unwrap_or(base) }
|
||||
});
|
||||
let mut cloud = PointCloud {
|
||||
id: 0,
|
||||
name: name.clone(),
|
||||
file,
|
||||
origin: read.origin,
|
||||
points: Points::new(read.points.into()),
|
||||
colors: Colors(read.colors.into()),
|
||||
source_points: read.source_points,
|
||||
units: units.into(),
|
||||
transform: solidcraft_doc::IDENTITY,
|
||||
point_size: size,
|
||||
color_by: ColorBy::File,
|
||||
color: None,
|
||||
height_range: None,
|
||||
visible: true,
|
||||
};
|
||||
set_colors(&mut cloud, colors.0, colors.1, colors.2);
|
||||
if center && let Some((lo, hi)) = cloud.local_bounds() {
|
||||
cloud.transform[3] = [-(lo.x + hi.x) / 2.0, -(lo.y + hi.y) / 2.0, -lo.z, 1.0];
|
||||
}
|
||||
cloud.check()?;
|
||||
let doc = s.doc_mut();
|
||||
let id = doc.allocate_id()?;
|
||||
cloud.id = id;
|
||||
let out = json!({
|
||||
"cloud": id,
|
||||
"name": name,
|
||||
"points": cloud.len(),
|
||||
"source_points": cloud.source_points,
|
||||
"bounds": bounds(&cloud),
|
||||
"warnings": warnings,
|
||||
});
|
||||
doc.point_clouds.push(cloud);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn find(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let key = match p.get("cloud") {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`cloud` must be an id or a name")),
|
||||
};
|
||||
s.doc.point_clouds.iter().position(|c| c.id.to_string() == key || c.name == key).ok_or_else(|| bad(cmd, format!("no point cloud `{key}`")))
|
||||
}
|
||||
|
||||
fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "pointcloud.edit";
|
||||
let i = find(s, p, cmd)?;
|
||||
let size = point_size(p, cmd)?;
|
||||
let colors = (color_by(p, cmd)?, color(p, cmd)?, height_range(p, cmd)?);
|
||||
let units = units_of(p, cmd)?;
|
||||
let placement = match p.get("matrix") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(m) => Some(super::component::matrix::rigid(m, super::component::matrix::tolerance(p, cmd)?, cmd)?),
|
||||
};
|
||||
let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
if let Some(n) = &name
|
||||
&& s.doc.point_clouds.iter().enumerate().any(|(k, c)| k != i && &c.name == n)
|
||||
{
|
||||
return Err(bad(cmd, format!("another point cloud is named `{n}`")));
|
||||
}
|
||||
let mut c = s.doc.point_clouds.get(i).cloned().ok_or_else(|| bad(cmd, "point cloud"))?;
|
||||
if let Some((unit, k)) = units {
|
||||
// The points came in as `c.units`: scale them to what they are in `unit`.
|
||||
let from = unit_scale(&c.units).unwrap_or(1.0);
|
||||
let f = k / from;
|
||||
if f != 1.0 {
|
||||
c.origin = c.origin * f;
|
||||
c.points = Points::new(c.points.values().iter().map(|v| (f64::from(*v) * f) as f32).collect());
|
||||
}
|
||||
c.units = unit.into();
|
||||
}
|
||||
if let Some(m) = placement {
|
||||
c.transform = m;
|
||||
}
|
||||
if let Some(v) = size {
|
||||
c.point_size = v;
|
||||
}
|
||||
set_colors(&mut c, colors.0, colors.1, colors.2);
|
||||
if let Some(v) = bool_(p, "visible") {
|
||||
c.visible = v;
|
||||
}
|
||||
if let Some(n) = name {
|
||||
c.name = n;
|
||||
}
|
||||
// A scale that puts a point out of range is refused, leaving the cloud as it was.
|
||||
c.check()?;
|
||||
let id = c.id;
|
||||
let slot = s.doc_mut().point_clouds.get_mut(i).ok_or_else(|| bad(cmd, "point cloud"))?;
|
||||
*slot = c;
|
||||
Ok(json!({"cloud": id}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = find(s, p, "pointcloud.delete")?;
|
||||
let c = s.doc_mut().point_clouds.remove(i);
|
||||
Ok(json!({"deleted": c.id}))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let clouds: Vec<Value> = s
|
||||
.doc
|
||||
.point_clouds
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"cloud": c.id,
|
||||
"name": c.name,
|
||||
"file": c.file,
|
||||
"points": c.len(),
|
||||
"source_points": c.source_points,
|
||||
"units": c.units,
|
||||
"visible": c.visible,
|
||||
"point_size": c.point_size,
|
||||
"color_by": c.color_by.name(),
|
||||
"color": c.color,
|
||||
"colored": !c.colors.is_empty(),
|
||||
"height_range": c.height_range_now(),
|
||||
"height_auto": c.height_range.is_none(),
|
||||
"bounds": bounds(c),
|
||||
"matrix": matrix_rows(&c.transform),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"clouds": clouds}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use solidcraft_doc::point_cloud::ColorBy;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn ply(dir: &std::path::Path, name: &str, points: &[[f32; 3]], colors: bool) -> String {
|
||||
let mut b = format!(
|
||||
"ply\nformat binary_little_endian 1.0\ncomment a test scan\nelement vertex {}\nproperty float x\nproperty float y\nproperty float z\n",
|
||||
points.len()
|
||||
);
|
||||
if colors {
|
||||
b.push_str("property uchar red\nproperty uchar green\nproperty uchar blue\n");
|
||||
}
|
||||
b.push_str("end_header\n");
|
||||
let mut b = b.into_bytes();
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
for v in p {
|
||||
b.extend(v.to_le_bytes());
|
||||
}
|
||||
if colors {
|
||||
b.extend([i as u8, 100, 200]);
|
||||
}
|
||||
}
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, b).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
fn dir(test: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("solidcraft-pointcloud-{test}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_a_scan_in_metres_edit_it_and_save_it_with_the_design() {
|
||||
let d = dir("insert");
|
||||
let path = ply(&d, "room.ply", &[[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 3.0, 1.5], [10.0, 20.0, 2.5]], true);
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("pointcloud.insert", &json!({"path": path, "units": "m"})).unwrap();
|
||||
assert_eq!((r["name"].as_str(), r["points"].as_u64(), r["source_points"].as_u64()), (Some("room"), Some(4), Some(4)));
|
||||
assert_eq!(r["bounds"], json!([[0.0, 0.0, 0.0], [10000.0, 20000.0, 2500.0]]));
|
||||
let id = r["cloud"].as_u64().unwrap();
|
||||
let c = &s.doc.point_clouds[0];
|
||||
assert_eq!(c.colors.get(2), Some([2, 100, 200]));
|
||||
assert_eq!(c.world_point(2).map(|p| [p.x, p.y, p.z]), Some([2000.0, 3000.0, 1500.0]));
|
||||
// The file was in millimetres after all; then moved 100 mm along X.
|
||||
s.execute("pointcloud.edit", &json!({"cloud": "room", "units": "mm", "point_size": 4, "color": [1, 0.5, 0]})).unwrap();
|
||||
s.execute("pointcloud.edit", &json!({"cloud": id, "matrix": [1, 0, 0, 100, 0, 1, 0, 0, 0, 0, 1, 0]})).unwrap();
|
||||
let c = &s.doc.point_clouds[0];
|
||||
assert_eq!(c.world_point(3).map(|p| [p.x, p.y, p.z]), Some([110.0, 20.0, 2.5]));
|
||||
assert_eq!((c.point_size, c.color, c.color_by), (4.0, Some([1.0, 0.5, 0.0]), ColorBy::Single));
|
||||
// By height, its own range, then the automatic one again; back to the file's colours.
|
||||
s.execute("pointcloud.edit", &json!({"cloud": id, "color_by": "height", "height_range": [0, 10]})).unwrap();
|
||||
let listed = s.execute("pointcloud.list", &json!({})).unwrap();
|
||||
assert_eq!((listed["clouds"][0]["color_by"].as_str(), listed["clouds"][0]["height_range"].clone()), (Some("height"), json!([0.0, 10.0])));
|
||||
s.execute("pointcloud.edit", &json!({"cloud": id, "height_range": null})).unwrap();
|
||||
let listed = s.execute("pointcloud.list", &json!({})).unwrap();
|
||||
assert_eq!(listed["clouds"][0]["height_auto"], true);
|
||||
assert_eq!(s.doc.point_clouds[0].color_by, ColorBy::Height);
|
||||
s.execute("pointcloud.edit", &json!({"cloud": id, "color": null})).unwrap();
|
||||
assert_eq!(s.doc.point_clouds[0].color_by, ColorBy::File);
|
||||
// The single colour was kept meanwhile.
|
||||
s.execute("pointcloud.edit", &json!({"cloud": id, "color_by": "single"})).unwrap();
|
||||
assert_eq!(s.doc.point_clouds[0].color, Some([1.0, 0.5, 0.0]));
|
||||
let listed = s.execute("pointcloud.list", &json!({})).unwrap();
|
||||
assert_eq!(listed["clouds"][0]["units"], "mm");
|
||||
assert_eq!(listed["clouds"][0]["matrix"][3], 100.0);
|
||||
// Undo-able; saved and read back with the design file, points and all.
|
||||
s.execute("pointcloud.edit", &json!({"cloud": id, "visible": false})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.point_clouds[0].visible);
|
||||
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(back.point_clouds, s.doc.point_clouds);
|
||||
let file = solidcraft_io::write_design_container(&s.doc).unwrap();
|
||||
let opened = solidcraft_io::read_design(&file).unwrap();
|
||||
assert_eq!(opened.point_clouds, s.doc.point_clouds);
|
||||
s.execute("pointcloud.delete", &json!({"cloud": id})).unwrap();
|
||||
assert!(s.doc.point_clouds.is_empty());
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_scan_is_thinned_and_centred() {
|
||||
let d = dir("thin");
|
||||
let points: Vec<[f32; 3]> = (0..100).map(|i| [i as f32, 1000.0, 50.0 + (i % 2) as f32]).collect();
|
||||
let path = ply(&d, "line.ply", &points, false);
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("pointcloud.insert", &json!({"path": path, "max_points": 10, "center": true, "name": "Line"})).unwrap();
|
||||
assert_eq!((r["points"].as_u64(), r["source_points"].as_u64()), (Some(10), Some(100)));
|
||||
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
// The middle of its base at the origin.
|
||||
assert_eq!(r["bounds"], json!([[-45.0, 0.0, 0.0], [45.0, 0.0, 0.0]]));
|
||||
assert!(s.doc.point_clouds[0].colors.is_empty());
|
||||
// A second insert of the same file gets a name of its own.
|
||||
let r = s.execute("pointcloud.insert", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["name"], "line");
|
||||
let r = s.execute("pointcloud.insert", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["name"], "line (2)");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_point_cloud_params_never_panic() {
|
||||
let d = dir("hostile");
|
||||
let path = ply(&d, "p.ply", &[[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], false);
|
||||
let bad = d.join("bad.ply");
|
||||
std::fs::write(
|
||||
&bad,
|
||||
b"ply\nformat binary_little_endian 1.0\nelement vertex 99999999999\nproperty float x\nproperty float y\nproperty float z\nend_header\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut s = Session::default();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"path": ""}),
|
||||
json!({"path": d.join("missing.ply").to_string_lossy()}),
|
||||
json!({"path": bad.to_string_lossy()}),
|
||||
json!({"path": d.join("a.stl").to_string_lossy()}),
|
||||
json!({"path": path, "units": "parsec"}),
|
||||
json!({"path": path, "units": 3}),
|
||||
json!({"path": path, "max_points": 0}),
|
||||
json!({"path": path, "max_points": -5}),
|
||||
json!({"path": path, "max_points": 1e30}),
|
||||
json!({"path": path, "point_size": 1e9}),
|
||||
json!({"path": path, "point_size": "big"}),
|
||||
json!({"path": path, "color": [2, 0, 0]}),
|
||||
json!({"path": path, "color": "red"}),
|
||||
json!({"path": path, "color_by": "rainbow"}),
|
||||
json!({"path": path, "height_range": [5, 1]}),
|
||||
json!({"path": path, "height_range": [0, "x"]}),
|
||||
json!({"path": path, "height_range": [0, 1e12]}),
|
||||
] {
|
||||
assert!(s.execute("pointcloud.insert", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.doc.point_clouds.is_empty());
|
||||
let id = s.execute("pointcloud.insert", &json!({"path": path})).unwrap()["cloud"].as_u64().unwrap();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"cloud": 999}),
|
||||
json!({"cloud": [1]}),
|
||||
json!({"cloud": id, "matrix": [2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}),
|
||||
json!({"cloud": id, "matrix": "x"}),
|
||||
json!({"cloud": id, "units": "km"}),
|
||||
json!({"cloud": id, "point_size": -1}),
|
||||
] {
|
||||
assert!(s.execute("pointcloud.edit", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("pointcloud.delete", &json!({"cloud": "nope"})).is_err());
|
||||
// Scaled out of range: refused, and the cloud is as it was.
|
||||
let far = ply(&d, "far.ply", &[[2e6, 0.0, 0.0], [2e6, 1.0, 0.0]], false);
|
||||
let far = s.execute("pointcloud.insert", &json!({"path": far})).unwrap()["cloud"].as_u64().unwrap();
|
||||
let before = s.doc.point_clouds.clone();
|
||||
assert!(s.execute("pointcloud.edit", &json!({"cloud": far, "units": "m"})).is_err());
|
||||
assert_eq!(s.doc.point_clouds, before);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,7 @@ pub(crate) fn script_steps(doc: &Document) -> Result<(Vec<Value>, Vec<String>)>
|
||||
(doc.appearances.is_empty(), "appearances (colours of bodies and faces)"),
|
||||
(doc.configs.is_empty(), "configurations"),
|
||||
(doc.canvases.is_empty(), "canvases (reference images)"),
|
||||
(doc.point_clouds.is_empty(), "point clouds"),
|
||||
(doc.named_views.is_empty(), "named views"),
|
||||
(doc.browser_groups.is_empty() && doc.browser_order.is_empty(), "Browser folders and order"),
|
||||
(doc.timeline_groups.is_empty(), "timeline folders"),
|
||||
|
||||
@@ -1864,6 +1864,7 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
|
||||
configs,
|
||||
favorites,
|
||||
canvases,
|
||||
point_clouds,
|
||||
param_comments,
|
||||
materials,
|
||||
user_materials,
|
||||
@@ -1898,6 +1899,7 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
|
||||
&& *configs == b.configs
|
||||
&& *favorites == b.favorites
|
||||
&& *canvases == b.canvases
|
||||
&& *point_clouds == b.point_clouds
|
||||
&& *param_comments == b.param_comments
|
||||
&& *materials == b.materials
|
||||
&& *user_materials == b.user_materials
|
||||
|
||||
@@ -110,6 +110,15 @@ pub fn bounds(s: &Session) -> Aabb3 {
|
||||
bounds_by(s, |body| body.mesh_bounds())
|
||||
}
|
||||
|
||||
/// The Point Clouds folder's tree key (`Visibility::hidden_folders`).
|
||||
pub const POINT_CLOUDS_FOLDER: &str = "c0/point_clouds";
|
||||
|
||||
/// The point clouds drawn now: shown, in a shown folder.
|
||||
pub fn shown_point_clouds(s: &Session) -> impl Iterator<Item = &solidcraft_doc::point_cloud::PointCloud> {
|
||||
let folder = !s.folder_hidden(POINT_CLOUDS_FOLDER);
|
||||
s.doc.point_clouds.iter().filter(move |c| folder && c.visible)
|
||||
}
|
||||
|
||||
/// [`bounds`] without meshing anything: a body not meshed yet counts with its outline
|
||||
/// ([`solidcraft_kernel::Body::outline_bounds`]), which a face curving out past all of its edges
|
||||
/// can exceed. Frames a design whose bodies are still being meshed.
|
||||
@@ -136,6 +145,11 @@ fn bounds_by(s: &Session, of: impl Fn(&solidcraft_doc::ModelBody) -> Aabb3) -> A
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in shown_point_clouds(s) {
|
||||
for p in c.world_corners().into_iter().flatten() {
|
||||
b.add(p);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces
|
||||
clip: scene.clip.map_or([0.0; 4], |(n, d)| [n.x as f32, n.y as f32, n.z as f32, d as f32]),
|
||||
cap: [f32::from(cap.0) / 255.0, f32::from(cap.1) / 255.0, f32::from(cap.2) / 255.0, if scene.plain_cap { 0.0 } else { 1.0 }],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis: analysis_uniform(mode, access.as_ref()),
|
||||
analysis_colors: scene.analysis.map_or([0.0; 36], |a| analysis_colors(&a.colors)),
|
||||
access,
|
||||
|
||||
@@ -908,6 +908,34 @@ pub struct GpuImage {
|
||||
/// Image vertex: position (3 × f32), uv (2 × f32), opacity (f32).
|
||||
const IMG_SIZE: usize = 24;
|
||||
|
||||
/// A point cloud: round dots of a size on screen, depth-tested and written like the model's
|
||||
/// faces.
|
||||
#[derive(Clone)]
|
||||
pub struct GpuPoints {
|
||||
pub id: u64,
|
||||
/// Changes when the points change (they are uploaded again).
|
||||
pub version: u64,
|
||||
/// [`CLOUD_POINT_SIZE`] bytes per point: position (3 × f32, in the design), colour (RGBA8,
|
||||
/// the alpha the dot's size in eighths of a pixel, see [`cloud_point`]).
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Bytes per point of a [`GpuPoints`].
|
||||
pub const CLOUD_POINT_SIZE: usize = 16;
|
||||
|
||||
/// A point of a [`GpuPoints`]: where it is, its colour and its size on screen (pixels, up to 31).
|
||||
pub fn cloud_point(out: &mut Vec<u8>, p: [f32; 3], rgb: [u8; 3], size: f32) {
|
||||
for v in p {
|
||||
out.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&rgb);
|
||||
out.push((size * 8.0).round().clamp(8.0, 255.0) as u8);
|
||||
}
|
||||
|
||||
fn point_layout() -> wgpu::VertexBufferLayout<'static> {
|
||||
const A: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Unorm8x4];
|
||||
wgpu::VertexBufferLayout { array_stride: CLOUD_POINT_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A }
|
||||
}
|
||||
/// Ambient occlusion samples per shaded pixel in a still picture (the shader's most).
|
||||
pub const AO_SAMPLES: u32 = 32;
|
||||
|
||||
@@ -944,6 +972,8 @@ pub struct Frame {
|
||||
pub cap: [f32; 4],
|
||||
/// Canvases and decals.
|
||||
pub images: Vec<GpuImage>,
|
||||
/// Point clouds.
|
||||
pub points: Vec<GpuPoints>,
|
||||
/// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature, 4 environment,
|
||||
/// 5 accessibility; parameter; scale; size) and the pull / access direction.
|
||||
pub analysis: [f32; 8],
|
||||
@@ -2016,6 +2046,10 @@ pub struct Renderer {
|
||||
textures: std::collections::HashMap<u64, (u64, wgpu::BindGroup)>,
|
||||
/// This frame's quads: (image id, vertices).
|
||||
quads: Vec<(u64, Batch)>,
|
||||
/// Point clouds' dots.
|
||||
point: wgpu::RenderPipeline,
|
||||
/// Point clouds by id: (version, points).
|
||||
clouds: std::collections::HashMap<u64, (u64, Batch)>,
|
||||
access_bgl: wgpu::BindGroupLayout,
|
||||
/// The accessibility depth map (a 1 × 1 stand-in when there is none) and its version.
|
||||
access_bind: wgpu::BindGroup,
|
||||
@@ -2827,6 +2861,7 @@ impl Renderer {
|
||||
pipeline("sc_guide_lines", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha),
|
||||
)
|
||||
};
|
||||
let point = || pipeline("sc_points", "vs_point", "fs_point", point_layout(), depth(wgpu::CompareFunction::Less, true), None);
|
||||
let guide_trans = || pipeline("sc_guide_trans", "vs_tri", "fs_tri_all", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha);
|
||||
// Each job on a thread of its own where the device allows it: D3D12 compiles every shader
|
||||
// as its pipeline is made, which took seconds one after another (#4860).
|
||||
@@ -2843,6 +2878,7 @@ impl Renderer {
|
||||
let lines = jobs.spawn(lines);
|
||||
let lines_all = jobs.spawn(lines_all);
|
||||
let guide_trans = jobs.spawn(guide_trans);
|
||||
let point = jobs.spawn(point);
|
||||
let image = jobs.spawn(image);
|
||||
let ao = jobs.spawn(ao);
|
||||
let oit = jobs.spawn(oit);
|
||||
@@ -2891,6 +2927,8 @@ impl Renderer {
|
||||
sampler,
|
||||
textures: Default::default(),
|
||||
quads: Vec::new(),
|
||||
point: point.join(),
|
||||
clouds: Default::default(),
|
||||
tri,
|
||||
tri_cut: cut(tri_marks, tri_cap),
|
||||
tri_planned,
|
||||
@@ -3210,6 +3248,10 @@ fn frame_key(cb: &Frame) -> u64 {
|
||||
img.corners.iter().flatten().for_each(|v| v.to_bits().hash(&mut h));
|
||||
img.opacity.to_bits().hash(&mut h);
|
||||
}
|
||||
for cloud in &cb.points {
|
||||
cloud.id.hash(&mut h);
|
||||
cloud.version.hash(&mut h);
|
||||
}
|
||||
match &cb.access {
|
||||
Some(a) => {
|
||||
true.hash(&mut h);
|
||||
@@ -3341,6 +3383,20 @@ impl Renderer {
|
||||
res.quads.push((img.id, b));
|
||||
}
|
||||
}
|
||||
// Point clouds: upload new or changed ones, drop removed ones.
|
||||
res.clouds.retain(|id, _| cb.points.iter().any(|c| c.id == *id));
|
||||
for c in &cb.points {
|
||||
if res.clouds.get(&c.id).is_none_or(|(v, _)| *v != c.version) {
|
||||
match upload(device, "sc_points", &c.bytes, CLOUD_POINT_SIZE) {
|
||||
Some(b) => {
|
||||
res.clouds.insert(c.id, (c.version, b));
|
||||
}
|
||||
None => {
|
||||
res.clouds.remove(&c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
res.ss_ready = (cb.ssaa != 1.0 || see_through || res.window_differs()) && res.supersample(device, encoder, cb, see_through);
|
||||
res.ss_key = res.ss_ready.then_some(key);
|
||||
}
|
||||
@@ -3569,6 +3625,16 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, scene_bind(m, &res.bind), &[]);
|
||||
tris(pass, &res.pbr, &m.pbr);
|
||||
if !cb.product {
|
||||
for (_, b) in res.clouds.values() {
|
||||
pass.set_pipeline(&res.point);
|
||||
for (buffer, count) in &b.parts {
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw(0..6, 0..*count);
|
||||
tally.triangles(6, *count);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Whole-body tints first, so a face or an edge highlighted over the body (in `h.tris`
|
||||
// and `h.lines`) keeps its own colour, as it did when the body's triangles were built
|
||||
// into the same list before them.
|
||||
@@ -4219,6 +4285,7 @@ mod tests {
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
access: None,
|
||||
@@ -4546,6 +4613,7 @@ mod glass_tests {
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
access: None,
|
||||
@@ -4718,6 +4786,7 @@ mod glass_tests {
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
access: None,
|
||||
@@ -4741,6 +4810,31 @@ mod glass_tests {
|
||||
scene
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_point_cloud_picture_updates_after_recoloring_and_hiding() {
|
||||
let Some((_, device, queue)) = crate::open_headless().ok() else { return };
|
||||
let depth = crate::depth_format(&device, true);
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let mut renderer = Renderer::new(&device, GpuTarget { format, depth: Some(depth), samples: 1, tess: false, oit: false }, None);
|
||||
let mut frame = test_frame(GpuScene::default(), 2.0);
|
||||
let mut bytes = Vec::new();
|
||||
cloud_point(&mut bytes, [0.0; 3], [255, 0, 0], 20.0);
|
||||
frame.points.push(GpuPoints { id: 1, version: 1, bytes: Arc::new(bytes) });
|
||||
let red = render_frame(&mut renderer, &device, &queue, &frame, true);
|
||||
assert!(red.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[1] < 30 && p[2] < 30), "the cloud is drawn");
|
||||
render_frame(&mut renderer, &device, &queue, &frame, true);
|
||||
assert!(renderer.cached.is_some());
|
||||
let mut bytes = Vec::new();
|
||||
cloud_point(&mut bytes, [0.0; 3], [0, 0, 255], 20.0);
|
||||
frame.points = vec![GpuPoints { id: 1, version: 2, bytes: Arc::new(bytes) }];
|
||||
let blue = render_frame(&mut renderer, &device, &queue, &frame, true);
|
||||
assert!(blue.as_chunks::<4>().0.iter().any(|p| p[0] < 30 && p[1] < 30 && p[2] > 200), "the edited cloud replaces the cached picture");
|
||||
assert_ne!(red, blue);
|
||||
frame.points.clear();
|
||||
let hidden = render_frame(&mut renderer, &device, &queue, &frame, true);
|
||||
assert!(!hidden.as_chunks::<4>().0.iter().any(|p| p[0] < 30 && p[1] < 30 && p[2] > 200), "hiding the cloud removes its dots");
|
||||
}
|
||||
|
||||
/// A still view shows the picture it drew last: a reused frame (whose filtered picture is
|
||||
/// cached once) draws the same pixels as the frame before it, and changing the camera draws
|
||||
/// again rather than keeping the cached picture.
|
||||
|
||||
@@ -111,6 +111,7 @@ impl Renderer {
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
access: None,
|
||||
|
||||
@@ -48,6 +48,7 @@ fn max_expanded() -> usize {
|
||||
max_step_part().saturating_add(max_design())
|
||||
}
|
||||
const MESH_MAGIC: &[u8; 8] = b"SCMESH01";
|
||||
const POINTS_MAGIC: &[u8; 8] = b"SCPNTS01";
|
||||
const MAX_PREVIEW_BYTES: usize = 8 << 20;
|
||||
const MAX_PREVIEW_TRIANGLES: usize = 100_000;
|
||||
|
||||
@@ -320,7 +321,18 @@ fn write_container(
|
||||
value.as_object_mut().ok_or_else(|| invalid("canvas metadata"))?.insert("image_blob".into(), blob);
|
||||
canvases.push(value);
|
||||
}
|
||||
let mut clouds = Vec::with_capacity(doc.point_clouds.len());
|
||||
for cloud in &doc.point_clouds {
|
||||
cloud.check().map_err(invalid)?;
|
||||
let blob = writer.blob(&encode_cloud(cloud)?, "points", compression == DesignCompression::Compact)?;
|
||||
let mut value = replace(cloud, fields([("points", json!("")), ("colors", json!(""))])).map_err(invalid)?;
|
||||
value.as_object_mut().ok_or_else(|| invalid("point cloud metadata"))?.insert("points_blob".into(), blob);
|
||||
clouds.push(value);
|
||||
}
|
||||
let mut replacements = fields([("features", Value::Array(features)), ("canvases", Value::Array(canvases))]);
|
||||
if !clouds.is_empty() {
|
||||
replacements.insert("point_clouds".into(), Value::Array(clouds));
|
||||
}
|
||||
if !doc.render.resources.is_empty() {
|
||||
let mut resources = Map::new();
|
||||
for (id, resource) in doc.render.resources.iter() {
|
||||
@@ -424,6 +436,56 @@ fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> {
|
||||
validate_mesh(mesh)
|
||||
}
|
||||
|
||||
/// A point cloud's points and colours: the magic, the point count and whether there are
|
||||
/// colours (u64 each, little-endian), then x, y, z per point (f32) and r, g, b per point.
|
||||
fn encode_cloud(cloud: &solidcraft_doc::point_cloud::PointCloud) -> Result<Vec<u8>> {
|
||||
let n = cloud.points.len();
|
||||
let colored = !cloud.colors.is_empty();
|
||||
let size = n
|
||||
.checked_mul(if colored { 15 } else { 12 })
|
||||
.and_then(|b| b.checked_add(24))
|
||||
.filter(|&b| b <= crate::limits::max_design_bytes())
|
||||
.ok_or_else(|| invalid("point cloud too large"))?;
|
||||
let mut bytes = Vec::with_capacity(size);
|
||||
bytes.extend_from_slice(POINTS_MAGIC);
|
||||
bytes.extend_from_slice(&(n as u64).to_le_bytes());
|
||||
bytes.extend_from_slice(&u64::from(colored).to_le_bytes());
|
||||
bytes.extend_from_slice(&cloud.points.to_bytes());
|
||||
if colored {
|
||||
bytes.extend_from_slice(&cloud.colors.0);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn decode_cloud(bytes: &[u8], cloud: &mut solidcraft_doc::point_cloud::PointCloud) -> Result<()> {
|
||||
use solidcraft_doc::point_cloud::{Colors, MAX_POINTS, Points};
|
||||
if bytes.get(..8) != Some(POINTS_MAGIC.as_slice()) {
|
||||
return Err(invalid("unsupported point cloud blob version"));
|
||||
}
|
||||
let word = |range: std::ops::Range<usize>| -> Result<u64> {
|
||||
let array: [u8; 8] = bytes.get(range).ok_or_else(|| invalid("truncated point cloud blob"))?.try_into().map_err(invalid)?;
|
||||
Ok(u64::from_le_bytes(array))
|
||||
};
|
||||
let n = usize::try_from(word(8..16)?).ok().filter(|&n| n <= MAX_POINTS).ok_or_else(|| invalid("point cloud too large"))?;
|
||||
let colored = match word(16..24)? {
|
||||
0 => false,
|
||||
1 => true,
|
||||
_ => return Err(invalid("invalid point cloud blob")),
|
||||
};
|
||||
let middle = 24 + n * 12;
|
||||
if bytes.len() != middle + if colored { n * 3 } else { 0 } {
|
||||
return Err(invalid("invalid point cloud blob lengths"));
|
||||
}
|
||||
cloud.points = Points::from_bytes(bytes.get(24..middle).ok_or_else(|| invalid("truncated point cloud"))?)
|
||||
.ok_or_else(|| invalid("invalid point coordinates"))?;
|
||||
cloud.colors = if colored {
|
||||
Colors::from_bytes(bytes.get(middle..).ok_or_else(|| invalid("truncated point cloud"))?).ok_or_else(|| invalid("invalid point colours"))?
|
||||
} else {
|
||||
Colors::default()
|
||||
};
|
||||
cloud.check().map_err(invalid)
|
||||
}
|
||||
|
||||
struct PackageReader<R: Read + Seek> {
|
||||
zip: zip::ZipArchive<R>,
|
||||
blobs: BTreeMap<String, Vec<u8>>,
|
||||
@@ -797,6 +859,13 @@ fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Docume
|
||||
}
|
||||
canvas.data = solidcraft_doc::canvas::base64_encode(bytes);
|
||||
}
|
||||
for (index, cloud) in doc.point_clouds.iter_mut().enumerate() {
|
||||
if !cloud.points.is_empty() || !cloud.colors.is_empty() {
|
||||
return Err(invalid("ambiguous embedded points and blob"));
|
||||
}
|
||||
let value = metadata.get("point_clouds").and_then(|v| v.get(index)).ok_or_else(|| invalid("missing point cloud metadata"))?;
|
||||
decode_cloud(reader.blob(value, "points_blob", "points")?, cloud)?;
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ pub enum ImportTarget {
|
||||
Brep,
|
||||
/// Mesh read as a MeshImport feature (`doc.open` / `file.insert_mesh`).
|
||||
Mesh,
|
||||
/// A reference scan, separate from model bodies (`doc.open` / `pointcloud.insert`).
|
||||
PointCloud,
|
||||
/// A 2D drawing read into the active sketch (`sketch.insert_dxf` / `sketch.insert_svg`).
|
||||
Sketch,
|
||||
}
|
||||
@@ -255,6 +257,30 @@ pub static FORMATS: &[FormatSpec] = &[
|
||||
import: Some(ImportSpec { target: ImportTarget::Mesh, options: &[UP_Y], native_only: false }),
|
||||
export: None,
|
||||
},
|
||||
FormatSpec {
|
||||
id: "ply",
|
||||
label: "PLY point cloud",
|
||||
extensions: &["ply"],
|
||||
role: Role::Artifact,
|
||||
import: Some(ImportSpec { target: ImportTarget::PointCloud, options: &[], native_only: false }),
|
||||
export: None,
|
||||
},
|
||||
FormatSpec {
|
||||
id: "xyz",
|
||||
label: "XYZ point cloud",
|
||||
extensions: &["xyz"],
|
||||
role: Role::Artifact,
|
||||
import: Some(ImportSpec { target: ImportTarget::PointCloud, options: &[], native_only: false }),
|
||||
export: None,
|
||||
},
|
||||
FormatSpec {
|
||||
id: "pts",
|
||||
label: "PTS point cloud",
|
||||
extensions: &["pts"],
|
||||
role: Role::Artifact,
|
||||
import: Some(ImportSpec { target: ImportTarget::PointCloud, options: &[], native_only: false }),
|
||||
export: None,
|
||||
},
|
||||
FormatSpec {
|
||||
id: "urdf",
|
||||
label: "URDF robot",
|
||||
@@ -374,6 +400,7 @@ pub fn import_filters(native: bool) -> Vec<(&'static str, Vec<&'static str>)> {
|
||||
ImportTarget::Brep if import.native_only => add("FreeCAD and Rhino", f.extensions),
|
||||
ImportTarget::Brep => add(f.label, f.extensions),
|
||||
ImportTarget::Mesh => add("Mesh (3MF, STL, OBJ, glTF)", f.extensions),
|
||||
ImportTarget::PointCloud => add("Point cloud (PLY, XYZ, PTS)", f.extensions),
|
||||
ImportTarget::Sketch => add("Sketch drawings (DXF, SVG)", f.extensions),
|
||||
}
|
||||
}
|
||||
@@ -419,6 +446,16 @@ mod tests {
|
||||
for e in ["3mf", "stl", "obj", "glb", "gltf"] {
|
||||
assert!(with_target(ImportTarget::Mesh).contains(&e), "registry misses mesh .{e}");
|
||||
}
|
||||
for e in ["ply", "xyz", "pts"] {
|
||||
assert!(with_target(ImportTarget::PointCloud).contains(&e), "registry misses point cloud .{e}");
|
||||
assert!(crate::is_point_cloud_path(&format!("scan.{e}")));
|
||||
assert!(open_extensions(true).contains(&e));
|
||||
assert!(open_extensions(false).contains(&e));
|
||||
assert_eq!(
|
||||
import_for_path(&format!("scan.{}", e.to_uppercase())).and_then(|f| f.import.as_ref()).map(|i| i.target),
|
||||
Some(ImportTarget::PointCloud)
|
||||
);
|
||||
}
|
||||
for e in with_target(ImportTarget::Design) {
|
||||
assert!(crate::is_design_path(&format!("part.{e}")), "is_design_path misses .{e}");
|
||||
}
|
||||
@@ -475,6 +512,7 @@ mod tests {
|
||||
assert_eq!(find("IGES"), vec!["igs", "iges"]);
|
||||
assert_eq!(find("FreeCAD and Rhino"), vec!["fcstd", "3dm"]);
|
||||
assert_eq!(find("Mesh (3MF, STL, OBJ, glTF)"), vec!["stl", "3mf", "obj", "glb", "gltf"]);
|
||||
assert_eq!(find("Point cloud (PLY, XYZ, PTS)"), vec!["ply", "xyz", "pts"]);
|
||||
assert_eq!(find("Sketch drawings (DXF, SVG)"), vec!["dxf", "svg"]);
|
||||
assert!(import_filters(false).iter().all(|(l, _)| *l != "FreeCAD and Rhino"));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! document rebuilds on load and optional display caches remain provisional. Bodies export
|
||||
//! as STL (binary or ASCII), OBJ, STEP and 3MF, and assemblies as URDF ([`urdf()`]); STEP, IGES,
|
||||
//! FreeCAD and Rhino files import as a base feature (see [`step_import_feature`]), 3MF and
|
||||
//! STL files as mesh bodies (see [`mesh_import_feature`]).
|
||||
//! STL files as mesh bodies (see [`mesh_import_feature`]), PLY and XYZ files as point clouds
|
||||
//! (see [`read_point_cloud`]).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -26,6 +27,10 @@ mod mutate;
|
||||
mod native_cad;
|
||||
pub use native_cad::{NATIVE_CAD_EXTS, is_native_cad_path, native_cad_to_step};
|
||||
pub mod paper;
|
||||
mod ply;
|
||||
pub use ply::{ply_counts, read_ply_mesh};
|
||||
mod point_cloud;
|
||||
pub use point_cloud::{CloudFile, DEFAULT_MAX_POINTS, MAX_POINT_CLOUD_BYTES, is_ply_path, is_point_cloud_path, read_point_cloud};
|
||||
pub mod safe_write;
|
||||
mod sketch2d;
|
||||
mod svg;
|
||||
@@ -277,7 +282,7 @@ pub struct MeshFeature {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read 3MF, STL, OBJ or glTF bytes (the format from `file`'s extension) into a MeshImport
|
||||
/// Read 3MF, STL, OBJ, PLY or glTF bytes (the format from `file`'s extension) into a MeshImport
|
||||
/// feature. A glTF file's bodies are all built where they are (see [`read_gltf`] for its node
|
||||
/// tree), and it can't read buffers in other files.
|
||||
pub fn mesh_import_feature(bytes: &[u8], file: &str) -> Result<MeshFeature> {
|
||||
@@ -295,6 +300,8 @@ pub fn mesh_import_feature(bytes: &[u8], file: &str) -> Result<MeshFeature> {
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
|
||||
let (objects, warnings) = if ext == "obj" {
|
||||
obj::read_obj(bytes, &stem)?
|
||||
} else if ext == "ply" {
|
||||
ply::read_ply_mesh(bytes, &stem)?
|
||||
} else if ext == "stl" {
|
||||
let tris = read_stl(bytes)?;
|
||||
if tris.is_empty() {
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
//! PLY (polygon file format) import, from the format's public description: a text header naming
|
||||
//! elements and their properties, then the elements' rows in ASCII or binary (little- or
|
||||
//! big-endian). The vertices (`x`, `y`, `z` of any numeric type, and `red`, `green`, `blue` when
|
||||
//! present) become a point cloud, and the faces (`vertex_indices`) a mesh. Every other element
|
||||
//! and property (normals, intensity, edges, …) is passed over. The file is untrusted: counts are
|
||||
//! checked against its size and capped.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::threemf::MeshObject;
|
||||
use crate::{IoError, Result};
|
||||
|
||||
/// Largest header read (bytes).
|
||||
const MAX_HEADER: usize = 1 << 20;
|
||||
const MAX_ELEMENTS: usize = 64;
|
||||
const MAX_PROPERTIES: usize = 256;
|
||||
/// Most indices in one face.
|
||||
const MAX_FACE: usize = 1024;
|
||||
/// Most vertices and triangles of a mesh read from one file (as OBJ).
|
||||
const MAX_MESH_VERTICES: usize = 20_000_000;
|
||||
const MAX_TRIANGLES: usize = 20_000_000;
|
||||
|
||||
fn invalid(m: impl std::fmt::Display) -> IoError {
|
||||
IoError::Invalid(format!("PLY: {m}"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Scalar {
|
||||
I8,
|
||||
U8,
|
||||
I16,
|
||||
U16,
|
||||
I32,
|
||||
U32,
|
||||
F32,
|
||||
F64,
|
||||
}
|
||||
|
||||
impl Scalar {
|
||||
fn parse(s: &str) -> Option<Scalar> {
|
||||
Some(match s {
|
||||
"char" | "int8" => Scalar::I8,
|
||||
"uchar" | "uint8" => Scalar::U8,
|
||||
"short" | "int16" => Scalar::I16,
|
||||
"ushort" | "uint16" => Scalar::U16,
|
||||
"int" | "int32" => Scalar::I32,
|
||||
"uint" | "uint32" => Scalar::U32,
|
||||
"float" | "float32" => Scalar::F32,
|
||||
"double" | "float64" => Scalar::F64,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn size(self) -> usize {
|
||||
match self {
|
||||
Scalar::I8 | Scalar::U8 => 1,
|
||||
Scalar::I16 | Scalar::U16 => 2,
|
||||
Scalar::I32 | Scalar::U32 | Scalar::F32 => 4,
|
||||
Scalar::F64 => 8,
|
||||
}
|
||||
}
|
||||
/// The value at the start of `b`.
|
||||
fn read(self, b: &[u8], big: bool) -> Option<f64> {
|
||||
fn take<const N: usize>(b: &[u8], big: bool) -> Option<[u8; N]> {
|
||||
let mut a: [u8; N] = b.get(..N)?.try_into().ok()?;
|
||||
if big {
|
||||
a.reverse();
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
Some(match self {
|
||||
Scalar::I8 => f64::from(i8::from_le_bytes(take(b, big)?)),
|
||||
Scalar::U8 => f64::from(*b.first()?),
|
||||
Scalar::I16 => f64::from(i16::from_le_bytes(take(b, big)?)),
|
||||
Scalar::U16 => f64::from(u16::from_le_bytes(take(b, big)?)),
|
||||
Scalar::I32 => f64::from(i32::from_le_bytes(take(b, big)?)),
|
||||
Scalar::U32 => f64::from(u32::from_le_bytes(take(b, big)?)),
|
||||
Scalar::F32 => f64::from(f32::from_le_bytes(take(b, big)?)),
|
||||
Scalar::F64 => f64::from_le_bytes(take(b, big)?),
|
||||
})
|
||||
}
|
||||
/// A colour channel of this type as a byte: integers by their range, floats from 0..1.
|
||||
fn channel(self, v: f64) -> u8 {
|
||||
let v = match self {
|
||||
Scalar::F32 | Scalar::F64 => v * 255.0,
|
||||
Scalar::U16 | Scalar::I16 => v / 257.0,
|
||||
Scalar::U32 | Scalar::I32 => v / 16_843_009.0,
|
||||
Scalar::U8 | Scalar::I8 => v,
|
||||
};
|
||||
if v.is_finite() { v.round().clamp(0.0, 255.0) as u8 } else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Kind {
|
||||
Scalar(Scalar),
|
||||
/// The count's type and the items' type.
|
||||
List(Scalar, Scalar),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Property {
|
||||
name: String,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Element {
|
||||
name: String,
|
||||
count: u64,
|
||||
properties: Vec<Property>,
|
||||
}
|
||||
|
||||
impl Element {
|
||||
/// Bytes per row in a binary file, when every property is a scalar.
|
||||
fn row_size(&self) -> Option<usize> {
|
||||
self.properties.iter().try_fold(0usize, |n, p| match p.kind {
|
||||
Kind::Scalar(s) => n.checked_add(s.size()),
|
||||
Kind::List(..) => None,
|
||||
})
|
||||
}
|
||||
fn find(&self, names: &[&str]) -> Option<usize> {
|
||||
names.iter().find_map(|n| self.properties.iter().position(|p| p.name.eq_ignore_ascii_case(n) && matches!(p.kind, Kind::Scalar(_))))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Encoding {
|
||||
Ascii,
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
/// What the header says, and where the rows start.
|
||||
#[derive(Debug)]
|
||||
struct Header {
|
||||
encoding: Encoding,
|
||||
elements: Vec<Element>,
|
||||
body: usize,
|
||||
}
|
||||
|
||||
/// Does `bytes` start like a PLY file?
|
||||
pub fn is_ply(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"ply") && bytes.get(3).is_some_and(|b| matches!(b, b'\n' | b'\r'))
|
||||
}
|
||||
|
||||
fn header(bytes: &[u8]) -> Result<Header> {
|
||||
if !is_ply(bytes) {
|
||||
return Err(invalid("not a PLY file (it must start with `ply`)"));
|
||||
}
|
||||
let head = bytes.get(..bytes.len().min(MAX_HEADER)).unwrap_or(bytes);
|
||||
let end = head.windows(10).position(|w| w == b"end_header").ok_or_else(|| invalid("no `end_header` in the first megabyte"))?;
|
||||
// The rows start after the end of that line (`\n`, `\r\n` or a lone `\r`).
|
||||
let mut body = end + 10;
|
||||
while head.get(body).is_some_and(|b| *b == b' ' || *b == b'\t') {
|
||||
body += 1;
|
||||
}
|
||||
match (head.get(body), head.get(body + 1)) {
|
||||
(Some(b'\r'), Some(b'\n')) => body += 2,
|
||||
(Some(b'\n' | b'\r'), _) => body += 1,
|
||||
(None, _) => {}
|
||||
_ => return Err(invalid("`end_header` is not on a line of its own")),
|
||||
}
|
||||
let text = String::from_utf8_lossy(head.get(..end).unwrap_or_default());
|
||||
let mut encoding = None;
|
||||
let mut elements: Vec<Element> = Vec::new();
|
||||
for line in text.split(['\n', '\r']).skip(1) {
|
||||
let mut it = line.split_whitespace();
|
||||
match it.next() {
|
||||
Some("format") => {
|
||||
encoding = Some(match it.next() {
|
||||
Some("ascii") => Encoding::Ascii,
|
||||
Some("binary_little_endian") => Encoding::Little,
|
||||
Some("binary_big_endian") => Encoding::Big,
|
||||
other => return Err(invalid(format!("unknown format `{}`", other.unwrap_or("")))),
|
||||
});
|
||||
}
|
||||
Some("element") => {
|
||||
let name = it.next().ok_or_else(|| invalid("an element without a name"))?.to_string();
|
||||
let count = it.next().and_then(|c| c.parse::<u64>().ok()).ok_or_else(|| invalid(format!("element `{name}` has no count")))?;
|
||||
if elements.len() >= MAX_ELEMENTS {
|
||||
return Err(invalid("too many elements"));
|
||||
}
|
||||
elements.push(Element { name, count, properties: Vec::new() });
|
||||
}
|
||||
Some("property") => {
|
||||
let e = elements.last_mut().ok_or_else(|| invalid("a property before any element"))?;
|
||||
if e.properties.len() >= MAX_PROPERTIES {
|
||||
return Err(invalid("too many properties"));
|
||||
}
|
||||
let first = it.next().unwrap_or("");
|
||||
let kind = if first == "list" {
|
||||
let count = it.next().and_then(Scalar::parse).ok_or_else(|| invalid("a list property without a count type"))?;
|
||||
let item = it.next().and_then(Scalar::parse).ok_or_else(|| invalid("a list property without an item type"))?;
|
||||
if matches!(count, Scalar::F32 | Scalar::F64) {
|
||||
return Err(invalid("a list's count must be an integer"));
|
||||
}
|
||||
Kind::List(count, item)
|
||||
} else {
|
||||
Kind::Scalar(Scalar::parse(first).ok_or_else(|| invalid(format!("unknown property type `{first}`")))?)
|
||||
};
|
||||
let name = it.next().ok_or_else(|| invalid("a property without a name"))?.to_string();
|
||||
e.properties.push(Property { name, kind });
|
||||
}
|
||||
// `comment`, `obj_info` and blank lines.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let encoding = encoding.ok_or_else(|| invalid("no `format` line"))?;
|
||||
Ok(Header { encoding, elements, body })
|
||||
}
|
||||
|
||||
/// A row's values, read one after another.
|
||||
enum Rows<'a> {
|
||||
Binary { b: &'a [u8], pos: usize, big: bool },
|
||||
Ascii { tokens: std::slice::Split<'a, u8, fn(&u8) -> bool> },
|
||||
}
|
||||
|
||||
fn space(b: &u8) -> bool {
|
||||
b.is_ascii_whitespace()
|
||||
}
|
||||
|
||||
impl<'a> Rows<'a> {
|
||||
fn new(h: &Header, bytes: &'a [u8]) -> Rows<'a> {
|
||||
let b = bytes.get(h.body..).unwrap_or_default();
|
||||
match h.encoding {
|
||||
Encoding::Ascii => Rows::Ascii { tokens: b.split(space as fn(&u8) -> bool) },
|
||||
Encoding::Little => Rows::Binary { b, pos: 0, big: false },
|
||||
Encoding::Big => Rows::Binary { b, pos: 0, big: true },
|
||||
}
|
||||
}
|
||||
fn value(&mut self, t: Scalar) -> Result<f64> {
|
||||
match self {
|
||||
Rows::Binary { b, pos, big } => {
|
||||
let v = b.get(*pos..).and_then(|r| t.read(r, *big)).ok_or_else(|| invalid("the file ends early"))?;
|
||||
*pos += t.size();
|
||||
Ok(v)
|
||||
}
|
||||
Rows::Ascii { tokens } => loop {
|
||||
let tok = tokens.next().ok_or_else(|| invalid("the file ends early"))?;
|
||||
if tok.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let s = std::str::from_utf8(tok).map_err(|_| invalid("a value is not a number"))?;
|
||||
// Integers written as decimals (`255.0`) are taken too.
|
||||
return s.parse::<f64>().map_err(|_| invalid(format!("`{}` is not a number", s.chars().take(32).collect::<String>())));
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Pass over `n` values of type `t`.
|
||||
fn skip(&mut self, t: Scalar, n: usize) -> Result<()> {
|
||||
match self {
|
||||
Rows::Binary { b, pos, .. } => {
|
||||
*pos = n
|
||||
.checked_mul(t.size())
|
||||
.and_then(|k| pos.checked_add(k))
|
||||
.filter(|p| *p <= b.len())
|
||||
.ok_or_else(|| invalid("the file ends early"))?;
|
||||
Ok(())
|
||||
}
|
||||
Rows::Ascii { .. } => {
|
||||
for _ in 0..n {
|
||||
self.value(t)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
fn count(&mut self, t: Scalar) -> Result<usize> {
|
||||
let n = self.value(t)?;
|
||||
if !(n >= 0.0 && n <= MAX_FACE as f64 && n.fract() == 0.0) {
|
||||
return Err(invalid("a list is longer than SolidCraft reads"));
|
||||
}
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// What to read from an element's rows.
|
||||
struct Wanted {
|
||||
/// Property indices of x, y, z.
|
||||
xyz: Option<[usize; 3]>,
|
||||
/// Property indices of red, green, blue and their types.
|
||||
rgb: Option<[(usize, Scalar); 3]>,
|
||||
/// The face list's property index.
|
||||
faces: Option<usize>,
|
||||
}
|
||||
|
||||
impl Wanted {
|
||||
fn of(e: &Element) -> Wanted {
|
||||
let scalar = |i: usize| match e.properties.get(i).map(|p| p.kind) {
|
||||
Some(Kind::Scalar(s)) => Some(s),
|
||||
_ => None,
|
||||
};
|
||||
let is_vertex = e.name == "vertex";
|
||||
let xyz = is_vertex.then(|| Some([e.find(&["x"])?, e.find(&["y"])?, e.find(&["z"])?])).flatten();
|
||||
let rgb = is_vertex
|
||||
.then(|| {
|
||||
let r = e.find(&["red", "r", "diffuse_red"])?;
|
||||
let g = e.find(&["green", "g", "diffuse_green"])?;
|
||||
let b = e.find(&["blue", "b", "diffuse_blue"])?;
|
||||
Some([(r, scalar(r)?), (g, scalar(g)?), (b, scalar(b)?)])
|
||||
})
|
||||
.flatten();
|
||||
let faces = (e.name == "face")
|
||||
.then(|| {
|
||||
e.properties.iter().position(|p| matches!(p.name.as_str(), "vertex_indices" | "vertex_index") && matches!(p.kind, Kind::List(..)))
|
||||
})
|
||||
.flatten();
|
||||
Wanted { xyz, rgb, faces }
|
||||
}
|
||||
}
|
||||
|
||||
/// A vertex as read: its position (file units) and colour.
|
||||
pub(crate) type Vertex = ([f64; 3], Option<[u8; 3]>);
|
||||
|
||||
/// A PLY file's vertices, rows `keep` says to keep, and their count in the file. Faces are read
|
||||
/// into `faces` (a fan of triangles per face) when given.
|
||||
struct Read {
|
||||
vertices: u64,
|
||||
colored: bool,
|
||||
bad_faces: usize,
|
||||
}
|
||||
|
||||
fn walk(bytes: &[u8], keep: &mut dyn FnMut(u64) -> bool, on_vertex: &mut dyn FnMut(Vertex), mut faces: Option<&mut Vec<[u32; 3]>>) -> Result<Read> {
|
||||
let h = header(bytes)?;
|
||||
let vertex = h.elements.iter().find(|e| e.name == "vertex").ok_or_else(|| invalid("no `vertex` element"))?;
|
||||
let w = Wanted::of(vertex);
|
||||
if w.xyz.is_none() {
|
||||
return Err(invalid("the vertices have no `x`, `y` and `z`"));
|
||||
}
|
||||
let mut out = Read { vertices: vertex.count, colored: w.rgb.is_some(), bad_faces: 0 };
|
||||
let body = bytes.len().saturating_sub(h.body) as u64;
|
||||
let mut rows = Rows::new(&h, bytes);
|
||||
let mut slots = vec![0.0f64; MAX_PROPERTIES];
|
||||
let mut face: Vec<f64> = Vec::new();
|
||||
for e in &h.elements {
|
||||
let w = Wanted::of(e);
|
||||
let wanted_faces = w.faces.is_some() && faces.is_some();
|
||||
// A binary element of scalars that isn't needed (or has rows to pass over) is a block
|
||||
// of bytes: check its size against the file before reading any of it.
|
||||
if let (Rows::Binary { b, pos, .. }, Some(size)) = (&mut rows, e.row_size()) {
|
||||
let total = e
|
||||
.count
|
||||
.checked_mul(size as u64)
|
||||
.filter(|n| *n <= (b.len() - *pos) as u64)
|
||||
.ok_or_else(|| invalid(format!("the file is too short for its {} {}", e.count, e.name)))?;
|
||||
if w.xyz.is_none() {
|
||||
*pos += total as usize;
|
||||
continue;
|
||||
}
|
||||
// The vertices: only the rows kept are read.
|
||||
let start = *pos;
|
||||
let big = matches!(h.encoding, Encoding::Big);
|
||||
let offsets: Vec<usize> = e
|
||||
.properties
|
||||
.iter()
|
||||
.scan(0usize, |o, p| {
|
||||
let at = *o;
|
||||
*o += match p.kind {
|
||||
Kind::Scalar(s) => s.size(),
|
||||
Kind::List(..) => 0,
|
||||
};
|
||||
Some(at)
|
||||
})
|
||||
.collect();
|
||||
let get = |row: &[u8], i: usize| -> Option<f64> {
|
||||
match e.properties.get(i)?.kind {
|
||||
Kind::Scalar(s) => s.read(row.get(*offsets.get(i)?..)?, big),
|
||||
Kind::List(..) => None,
|
||||
}
|
||||
};
|
||||
for r in 0..e.count {
|
||||
if !keep(r) {
|
||||
continue;
|
||||
}
|
||||
let at = start + (r as usize) * size;
|
||||
let row = b.get(at..at + size).ok_or_else(|| invalid("the file ends early"))?;
|
||||
let Some([x, y, z]) = w.xyz else { break };
|
||||
let p = [get(row, x), get(row, y), get(row, z)];
|
||||
let [Some(x), Some(y), Some(z)] = p else { return Err(invalid("the file ends early")) };
|
||||
let rgb = match w.rgb.map(|c| c.map(|(i, t)| get(row, i).map(|v| t.channel(v)))) {
|
||||
Some([Some(r), Some(g), Some(b)]) => Some([r, g, b]),
|
||||
_ => None,
|
||||
};
|
||||
on_vertex(([x, y, z], rgb));
|
||||
}
|
||||
*pos += total as usize;
|
||||
// A point cloud needs nothing after its vertices.
|
||||
if faces.is_none() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Row by row: ASCII, or rows with lists. A row takes at least a byte, so a count larger
|
||||
// than the file is damage, not a long loop.
|
||||
if e.count > body {
|
||||
return Err(invalid(format!("the file is too short for its {} {}", e.count, e.name)));
|
||||
}
|
||||
for r in 0..e.count {
|
||||
for (i, p) in e.properties.iter().enumerate() {
|
||||
match p.kind {
|
||||
Kind::Scalar(t) => {
|
||||
let v = rows.value(t)?;
|
||||
if let Some(s) = slots.get_mut(i) {
|
||||
*s = v;
|
||||
}
|
||||
}
|
||||
Kind::List(c, t) => {
|
||||
let n = rows.count(c)?;
|
||||
if wanted_faces && w.faces == Some(i) {
|
||||
face.clear();
|
||||
for _ in 0..n {
|
||||
face.push(rows.value(t)?);
|
||||
}
|
||||
} else {
|
||||
rows.skip(t, n)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some([x, y, z]) = w.xyz {
|
||||
if keep(r) {
|
||||
let v = |i: usize| slots.get(i).copied().unwrap_or(0.0);
|
||||
let rgb = w.rgb.map(|c| c.map(|(i, t)| t.channel(v(i))));
|
||||
on_vertex(([v(x), v(y), v(z)], rgb));
|
||||
}
|
||||
} else if wanted_faces && let Some(tris) = faces.as_deref_mut() {
|
||||
let n = out.vertices;
|
||||
let idx: Option<Vec<u32>> =
|
||||
face.iter().map(|v| (*v >= 0.0 && *v < n as f64 && v.fract() == 0.0).then(|| u32::try_from(*v as u64).ok()).flatten()).collect();
|
||||
match idx {
|
||||
Some(p) if p.len() >= 3 => {
|
||||
for k in 1..p.len() - 1 {
|
||||
tris.push([p[0], p[k], p[k + 1]]);
|
||||
}
|
||||
if tris.len() > MAX_TRIANGLES {
|
||||
return Err(invalid(format!("more than {MAX_TRIANGLES} triangles")));
|
||||
}
|
||||
}
|
||||
_ => out.bad_faces += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
if w.xyz.is_some() && faces.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The vertices of a PLY file, every `stride`th one (`1`: all), and how many the file has.
|
||||
/// The colour of each is `Some` when the file has colours.
|
||||
pub(crate) fn read_vertices(bytes: &[u8], stride: u64, on_vertex: &mut dyn FnMut(Vertex)) -> Result<(u64, bool)> {
|
||||
let stride = stride.max(1);
|
||||
let r = walk(bytes, &mut |i| i.is_multiple_of(stride), on_vertex, None)?;
|
||||
Ok((r.vertices, r.colored))
|
||||
}
|
||||
|
||||
/// How many vertices and faces a PLY file says it has (from its header alone).
|
||||
pub fn ply_counts(bytes: &[u8]) -> Result<(u64, u64)> {
|
||||
let h = header(bytes)?;
|
||||
let count = |name: &str| h.elements.iter().find(|e| e.name == name).map_or(0, |e| e.count);
|
||||
Ok((count("vertex"), count("face")))
|
||||
}
|
||||
|
||||
/// A PLY file's faces as a mesh named `name` (a fan of triangles per face; colours, normals and
|
||||
/// texture coordinates are not read), with what could not be read.
|
||||
pub fn read_ply_mesh(bytes: &[u8], name: &str) -> Result<(Vec<MeshObject>, Vec<String>)> {
|
||||
let (vertices, faces) = ply_counts(bytes)?;
|
||||
if faces == 0 {
|
||||
return Err(invalid("the file has no faces: it is a point cloud (insert it with pointcloud.insert)"));
|
||||
}
|
||||
if vertices > MAX_MESH_VERTICES as u64 {
|
||||
return Err(invalid(format!("more than {MAX_MESH_VERTICES} vertices")));
|
||||
}
|
||||
let mut positions: Vec<Vec3> = Vec::new();
|
||||
let mut triangles: Vec<[u32; 3]> = Vec::new();
|
||||
let mut finite = true;
|
||||
let r = walk(
|
||||
bytes,
|
||||
&mut |_| true,
|
||||
&mut |([x, y, z], _)| {
|
||||
finite &= x.is_finite() && y.is_finite() && z.is_finite();
|
||||
positions.push(Vec3::new(x, y, z));
|
||||
},
|
||||
Some(&mut triangles),
|
||||
)?;
|
||||
if !finite {
|
||||
return Err(invalid("a vertex is not a finite number"));
|
||||
}
|
||||
if triangles.is_empty() {
|
||||
return Err(invalid("no face could be read"));
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
if r.bad_faces > 0 {
|
||||
warnings.push(format!("{} face(s) could not be read and were left out", r.bad_faces));
|
||||
}
|
||||
Ok((vec![MeshObject { name: name.to_string(), positions, triangles, ..Default::default() }], warnings))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn vertices(bytes: &[u8], stride: u64) -> Result<(Vec<Vertex>, u64, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let (n, c) = read_vertices(bytes, stride, &mut |v| out.push(v))?;
|
||||
Ok((out, n, c))
|
||||
}
|
||||
|
||||
fn binary(big: bool) -> Vec<u8> {
|
||||
let order = if big { "big" } else { "little" };
|
||||
let mut b = format!(
|
||||
"ply\r\nformat binary_{order}_endian 1.0\r\ncomment made by hand\r\nelement camera 1\r\nproperty double k\r\nelement vertex 3\r\nproperty float32 x\r\nproperty float32 y\r\nproperty float32 z\r\nproperty uchar red\r\nproperty uchar green\r\nproperty uchar blue\r\nproperty float intensity\r\nelement face 1\r\nproperty list uchar int vertex_indices\r\nend_header\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let f = |v: f32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
b.extend(if big { 9.5f64.to_be_bytes() } else { 9.5f64.to_le_bytes() });
|
||||
for (i, p) in [[0.0f32, 0.0, 0.0], [1.5, 0.0, 0.0], [0.0, 2.5, -3.0]].iter().enumerate() {
|
||||
for v in p {
|
||||
b.extend(f(*v));
|
||||
}
|
||||
b.extend([10 * i as u8, 20, 255]);
|
||||
b.extend(f(0.5));
|
||||
}
|
||||
b.push(3);
|
||||
for i in [0i32, 1, 2] {
|
||||
b.extend(if big { i.to_be_bytes() } else { i.to_le_bytes() });
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_little_and_big_endian_with_colours_and_other_elements() {
|
||||
for big in [false, true] {
|
||||
let (v, n, colored) = vertices(&binary(big), 1).unwrap();
|
||||
assert_eq!((n, colored), (3, true));
|
||||
assert_eq!(v[1], ([1.5, 0.0, 0.0], Some([10, 20, 255])));
|
||||
assert_eq!(v[2].0, [0.0, 2.5, -3.0]);
|
||||
// Every second vertex.
|
||||
let (v, _, _) = vertices(&binary(big), 2).unwrap();
|
||||
assert_eq!(v.iter().map(|v| v.0[1]).collect::<Vec<_>>(), [0.0, 2.5]);
|
||||
let (mesh, warnings) = read_ply_mesh(&binary(big), "Tri").unwrap();
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(mesh[0].triangles, [[0, 1, 2]]);
|
||||
assert_eq!(ply_counts(&binary(big)).unwrap(), (3, 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_with_float_colours_lists_and_a_quad() {
|
||||
let src = "ply\nformat ascii 1.0\nelement vertex 4\nproperty double x\nproperty double y\nproperty double z\nproperty float red\nproperty float green\nproperty float blue\n\
|
||||
element face 1\nproperty list uchar uint vertex_index\nend_header\n\
|
||||
0 0 0 1 0 0\n1 0 0 0 1 0\n1 1 0 0 0 1\n0 1 0 0.5 0.5 0.5\n4 0 1 2 3\n";
|
||||
let (v, n, colored) = vertices(src.as_bytes(), 1).unwrap();
|
||||
assert_eq!((n, colored, v.len()), (4, true, 4));
|
||||
assert_eq!(v[0].1, Some([255, 0, 0]));
|
||||
assert_eq!(v[3], ([0.0, 1.0, 0.0], Some([128, 128, 128])));
|
||||
let (mesh, _) = read_ply_mesh(src.as_bytes(), "Quad").unwrap();
|
||||
assert_eq!(mesh[0].triangles, [[0, 1, 2], [0, 2, 3]]);
|
||||
// Only points (as the glim maps are written): no colours, no faces.
|
||||
let points =
|
||||
"ply\nformat ascii 1.0\nelement vertex 2\nproperty float32 x\nproperty float32 y\nproperty float32 z\nend_header\n1 2 3\n4 5 6\n";
|
||||
let (v, n, colored) = vertices(points.as_bytes(), 1).unwrap();
|
||||
assert_eq!((v.len(), n, colored), (2, 2, false));
|
||||
assert_eq!(v[1], ([4.0, 5.0, 6.0], None));
|
||||
assert!(read_ply_mesh(points.as_bytes(), "x").is_err(), "a point cloud is not a mesh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_ply_never_panics() {
|
||||
let good = binary(false);
|
||||
let mut cases: Vec<Vec<u8>> = vec![
|
||||
b"".to_vec(),
|
||||
b"ply".to_vec(),
|
||||
b"ply\nformat ascii 1.0\n".to_vec(),
|
||||
b"ply\nformat ascii 1.0\nelement vertex 18446744073709551615\nproperty float x\nproperty float y\nproperty float z\nend_header\n1 2 3".to_vec(),
|
||||
b"ply\nformat binary_little_endian 1.0\nelement vertex 18446744073709551615\nproperty float x\nproperty float y\nproperty float z\nend_header\n1234".to_vec(),
|
||||
b"ply\nformat binary_little_endian 1.0\nelement vertex 1\nproperty list uchar float x\nproperty float y\nproperty float z\nend_header\n\xff".to_vec(),
|
||||
b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nend_header\n1".to_vec(),
|
||||
b"ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0 1 0 0 0 1 0 3 0 1 99\n".to_vec(),
|
||||
b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nend_header\nnan inf -inf\n".to_vec(),
|
||||
b"ply\nformat ascii 1.0\nproperty float x\nend_header\n".to_vec(),
|
||||
b"ply\nformat binary_big_endian 1.0\nelement face 2\nproperty list float int vertex_indices\nend_header\n".to_vec(),
|
||||
];
|
||||
// Every truncation of a good file.
|
||||
for n in 0..good.len() {
|
||||
cases.push(good[..n].to_vec());
|
||||
}
|
||||
for c in &cases {
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let _ = vertices(c, 1);
|
||||
let _ = vertices(c, 3);
|
||||
let _ = read_ply_mesh(c, "x");
|
||||
let _ = ply_counts(c);
|
||||
});
|
||||
assert!(r.is_ok(), "{:?}", String::from_utf8_lossy(c));
|
||||
}
|
||||
assert!(vertices(&good[..good.len() - 30], 1).is_err(), "a cut-off file is refused");
|
||||
// A face naming a vertex that isn't there is left out, with a warning.
|
||||
let tri = "ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 2\nproperty list uchar int vertex_indices\nend_header\n0 0 0 1 0 0 0 1 0\n3 0 1 2\n3 0 1 99\n";
|
||||
let (mesh, warnings) = read_ply_mesh(tri.as_bytes(), "x").unwrap();
|
||||
assert_eq!((mesh[0].triangles.len(), warnings.len()), (1, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Point cloud files read for a design's point cloud (#5151): PLY ([`crate::ply`]) and XYZ text
|
||||
//! (`x y z` per line, optionally followed by `r g b`, or by an intensity and then `r g b` as PTS
|
||||
//! files write it; spaces, tabs, commas or semicolons between the values). A file with more
|
||||
//! points than asked for is thinned evenly (every n-th point). The points are kept relative to
|
||||
//! the first one, in single precision.
|
||||
|
||||
use solidcraft_doc::point_cloud::{MAX_COORD, MAX_POINTS};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::{IoError, Result};
|
||||
|
||||
/// Largest point cloud file read.
|
||||
pub const MAX_POINT_CLOUD_BYTES: usize = 1 << 30;
|
||||
/// Points kept from a file unless asked for another number.
|
||||
pub const DEFAULT_MAX_POINTS: usize = 5_000_000;
|
||||
/// Is this a point cloud file we read (`.ply`, `.xyz`, `.pts`, any case)?
|
||||
pub fn is_point_cloud_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| ["ply", "xyz", "pts"].iter().any(|x| e.eq_ignore_ascii_case(x)))
|
||||
}
|
||||
|
||||
/// Is this a PLY file (`.ply`, any case)?
|
||||
pub fn is_ply_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("ply"))
|
||||
}
|
||||
|
||||
/// A point cloud as read from a file, in millimetres.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CloudFile {
|
||||
/// The first point kept: the others are relative to it.
|
||||
pub origin: Vec3,
|
||||
/// x, y, z per point, from `origin`.
|
||||
pub points: Vec<f32>,
|
||||
/// r, g, b per point, or empty when the file has no colours.
|
||||
pub colors: Vec<u8>,
|
||||
/// Points in the file.
|
||||
pub source_points: u64,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Points kept as they are read.
|
||||
struct Keep {
|
||||
scale: f64,
|
||||
origin: Option<Vec3>,
|
||||
out: CloudFile,
|
||||
colored: bool,
|
||||
bad: u64,
|
||||
max: usize,
|
||||
}
|
||||
|
||||
impl Keep {
|
||||
fn push(&mut self, [x, y, z]: [f64; 3], rgb: Option<[u8; 3]>) {
|
||||
let p = Vec3::new(x, y, z) * self.scale;
|
||||
if ![p.x, p.y, p.z].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) || self.out.points.len() / 3 >= self.max {
|
||||
self.bad += 1;
|
||||
return;
|
||||
}
|
||||
let o = *self.origin.get_or_insert(p);
|
||||
let d = p - o;
|
||||
self.out.points.extend([d.x as f32, d.y as f32, d.z as f32]);
|
||||
if self.colored {
|
||||
self.out.colors.extend(rgb.unwrap_or([255; 3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a PLY or XYZ file (by `file`'s extension; a file starting with `ply` is PLY whatever its
|
||||
/// name) whose lengths are `scale` millimetres each, keeping at most `max_points` points.
|
||||
pub fn read_point_cloud(bytes: &[u8], file: &str, scale: f64, max_points: usize) -> Result<CloudFile> {
|
||||
if bytes.len() > MAX_POINT_CLOUD_BYTES {
|
||||
return Err(IoError::Invalid(format!("point cloud file too large ({} MB, limit {} MB)", bytes.len() >> 20, MAX_POINT_CLOUD_BYTES >> 20)));
|
||||
}
|
||||
if !(scale.is_finite() && scale > 0.0 && scale <= 1e6) {
|
||||
return Err(IoError::Invalid("the point cloud's scale must be positive".into()));
|
||||
}
|
||||
let max = max_points.clamp(1, MAX_POINTS);
|
||||
let mut keep = Keep { scale, origin: None, out: CloudFile::default(), colored: false, bad: 0, max };
|
||||
let ply = crate::ply::is_ply(bytes) || (is_ply_path(file) && !bytes.is_empty());
|
||||
let stride;
|
||||
if ply {
|
||||
let (count, _) = crate::ply::ply_counts(bytes)?;
|
||||
stride = count.div_ceil(max as u64).max(1);
|
||||
keep.out.points.reserve(count.div_ceil(stride).min(max as u64) as usize * 3);
|
||||
// Colours are known once the header is read: the first vertex says.
|
||||
let mut first = true;
|
||||
let (count, colored) = crate::ply::read_vertices(bytes, stride, &mut |(p, rgb)| {
|
||||
if first {
|
||||
keep.colored = rgb.is_some();
|
||||
first = false;
|
||||
}
|
||||
keep.push(p, rgb);
|
||||
})?;
|
||||
keep.out.source_points = count;
|
||||
keep.colored &= colored;
|
||||
} else {
|
||||
stride = read_xyz(bytes, &mut keep)?;
|
||||
}
|
||||
let mut out = keep.out;
|
||||
if out.points.is_empty() {
|
||||
return Err(IoError::Invalid("the file has no points SolidCraft can read".into()));
|
||||
}
|
||||
if keep.bad > 0 {
|
||||
out.warnings.push(format!("{} point(s) could not be read and were left out", keep.bad));
|
||||
}
|
||||
if stride > 1 {
|
||||
out.warnings.push(format!("{} of the file's {} points kept (one in {stride})", out.points.len() / 3, out.source_points));
|
||||
}
|
||||
out.origin = keep.origin.unwrap_or(Vec3::ZERO);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The numbers of an XYZ line, or `None` for a line that isn't data.
|
||||
fn numbers(line: &str, out: &mut Vec<f64>) -> bool {
|
||||
out.clear();
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
|
||||
return false;
|
||||
}
|
||||
for tok in line.split([' ', '\t', ',', ';']).filter(|t| !t.is_empty()) {
|
||||
match tok.parse::<f64>() {
|
||||
Ok(v) if out.len() < 16 => out.push(v),
|
||||
Ok(_) => {}
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Read an XYZ file into `keep`; returns the thinning stride.
|
||||
fn read_xyz(bytes: &[u8], keep: &mut Keep) -> Result<u64> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let lines = bytes.iter().filter(|b| **b == b'\n').count() as u64 + 1;
|
||||
let stride = lines.div_ceil(keep.max as u64).max(1);
|
||||
let mut v = Vec::with_capacity(16);
|
||||
// Where the colour is: after x, y, z (6 numbers), or after an intensity too (7, as PTS).
|
||||
let mut rgb_at: Option<Option<usize>> = None;
|
||||
let mut data = 0u64;
|
||||
for line in text.lines() {
|
||||
if !numbers(line, &mut v) {
|
||||
if !line.trim().is_empty() && !line.trim_start().starts_with('#') && !line.trim_start().starts_with("//") {
|
||||
keep.bad += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A PTS file's first line is its count.
|
||||
if v.len() < 3 {
|
||||
if data > 0 || v.len() != 1 {
|
||||
keep.bad += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let at = *rgb_at.get_or_insert(match v.len() {
|
||||
6 => Some(3),
|
||||
n if n >= 7 => Some(4),
|
||||
_ => None,
|
||||
});
|
||||
keep.colored = at.is_some();
|
||||
data += 1;
|
||||
if !(data - 1).is_multiple_of(stride) {
|
||||
continue;
|
||||
}
|
||||
let rgb = at.and_then(|i| {
|
||||
let c = |k: usize| v.get(i + k).filter(|c| c.is_finite()).map(|c| c.round().clamp(0.0, 255.0) as u8);
|
||||
Some([c(0)?, c(1)?, c(2)?])
|
||||
});
|
||||
keep.push([v[0], v[1], v[2]], rgb);
|
||||
}
|
||||
keep.out.source_points = data;
|
||||
Ok(stride)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ply_points(n: usize) -> Vec<u8> {
|
||||
let mut b = format!(
|
||||
"ply\nformat binary_little_endian 1.0\nelement vertex {n}\nproperty float32 x\nproperty float32 y\nproperty float32 z\nend_header\n"
|
||||
)
|
||||
.into_bytes();
|
||||
for i in 0..n {
|
||||
for v in [i as f32, 0.5, 100_000.0] {
|
||||
b.extend(v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ply_in_metres_is_kept_relative_to_its_first_point_and_thinned() {
|
||||
let c = read_point_cloud(&ply_points(10), "scan.ply", 1000.0, 5).unwrap();
|
||||
assert_eq!(c.source_points, 10);
|
||||
assert_eq!(c.points.len(), 15, "every second point");
|
||||
assert_eq!(c.origin, Vec3::new(0.0, 500.0, 1e8));
|
||||
assert_eq!(&c.points[3..6], &[2000.0, 0.0, 0.0]);
|
||||
assert!(c.colors.is_empty());
|
||||
assert_eq!(c.warnings.len(), 1, "{:?}", c.warnings);
|
||||
let all = read_point_cloud(&ply_points(10), "scan.PLY", 1.0, DEFAULT_MAX_POINTS).unwrap();
|
||||
assert_eq!((all.points.len(), all.warnings.len()), (30, 0));
|
||||
assert!(read_point_cloud(&ply_points(10), "scan.ply", 0.0, 5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xyz_and_pts_text() {
|
||||
let xyz = "# x y z r g b\n1 2 3 255 0 0\n4,5,6,0,255,0\n\n7\t8\t9\t0\t0\t255\nnot a point\n";
|
||||
let c = read_point_cloud(xyz.as_bytes(), "a.xyz", 1.0, DEFAULT_MAX_POINTS).unwrap();
|
||||
assert_eq!(c.source_points, 3);
|
||||
assert_eq!(c.origin, Vec3::new(1.0, 2.0, 3.0));
|
||||
assert_eq!(c.points, [0.0, 0.0, 0.0, 3.0, 3.0, 3.0, 6.0, 6.0, 6.0]);
|
||||
assert_eq!(c.colors, [255, 0, 0, 0, 255, 0, 0, 0, 255]);
|
||||
assert_eq!(c.warnings.len(), 1, "the line that isn't a point");
|
||||
let pts = "2\n0 0 0 -500 10 20 30\n1 1 1 -400 40 50 60\n";
|
||||
let c = read_point_cloud(pts.as_bytes(), "a.pts", 25.4, DEFAULT_MAX_POINTS).unwrap();
|
||||
assert_eq!((c.source_points, c.warnings.len()), (2, 0));
|
||||
assert_eq!(c.colors, [10, 20, 30, 40, 50, 60]);
|
||||
assert!((c.points[3] - 25.4).abs() < 1e-6);
|
||||
let plain = read_point_cloud(b"0 0 0\n1 0 0\n", "a.xyz", 1.0, 1).unwrap();
|
||||
assert_eq!(plain.points.len(), 3, "thinned to one point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_point_clouds_never_panic() {
|
||||
for (b, name) in [
|
||||
(&b""[..], "a.ply"),
|
||||
(b"", "a.xyz"),
|
||||
(b"1e400 0 0\n", "a.xyz"),
|
||||
(b"nan nan nan\n", "a.xyz"),
|
||||
(b"1 2\n3\n", "a.xyz"),
|
||||
(b"\xff\xfe\x00", "a.xyz"),
|
||||
(b"ply\nformat ascii 1.0\nelement vertex 1\nproperty float x\nproperty float y\nproperty float z\nend_header\n1e300 0 0\n", "a.ply"),
|
||||
(b"PLY", "b.ply"),
|
||||
] {
|
||||
let r = std::panic::catch_unwind(|| read_point_cloud(b, name, 1000.0, DEFAULT_MAX_POINTS).is_ok());
|
||||
assert_eq!(r.ok(), Some(false), "{:?}", String::from_utf8_lossy(b));
|
||||
}
|
||||
assert!(is_point_cloud_path("x.PLY") && is_point_cloud_path("y.xyz") && is_point_cloud_path("z.pts") && !is_point_cloud_path("w.stl"));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ mod occurrence_gizmo_tests;
|
||||
mod model;
|
||||
|
||||
mod features;
|
||||
mod point_clouds;
|
||||
pub use point_clouds::CloudPanel;
|
||||
|
||||
pub(crate) fn reveal_feature(app: &mut SolveApp, id: u64, body: &str) {
|
||||
features::reveal(app, id, body);
|
||||
@@ -135,6 +137,8 @@ pub struct TreeState {
|
||||
/// The bodies a row at the top of the history lights this frame (`history_rows`), for the
|
||||
/// browser, drawn after it, to keep.
|
||||
history_hover: Vec<String>,
|
||||
/// The Edit Point Cloud panel.
|
||||
pub cloud_panel: Option<CloudPanel>,
|
||||
/// The plane a row at the top of the history lights this frame, kept the same way (#6975).
|
||||
history_plane: Option<String>,
|
||||
/// A Section Analysis hidden with its eye in the Analysis folder: its plane (origin,
|
||||
@@ -1224,6 +1228,7 @@ fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, mut acts: Actions) {
|
||||
crate::browser_prompts::occurrence_panel(app, ui);
|
||||
crate::browser_prompts::capture_prompt(app, ui.ctx());
|
||||
crate::browser_canvas::panel(app, ui.ctx());
|
||||
point_clouds::panel(app, ui.ctx());
|
||||
crate::sketch_attachment_ui::panel(app, ui.ctx());
|
||||
}
|
||||
|
||||
@@ -1357,6 +1362,7 @@ fn component_rows_inner(
|
||||
named_view_rows(app, ui, d, acts);
|
||||
origin_rows(app, ui, d, acts);
|
||||
canvas_rows(app, ui, d, acts);
|
||||
point_clouds::rows(app, ui, d, acts);
|
||||
analysis_rows(app, ui, d, acts);
|
||||
}
|
||||
let render = crate::render_workspace::active(app);
|
||||
@@ -1918,6 +1924,11 @@ fn canvas_rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut A
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu of a point cloud.
|
||||
pub fn point_cloud_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
point_clouds::items(app, id)
|
||||
}
|
||||
|
||||
/// The menu of a canvas.
|
||||
pub fn canvas_items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
let visible = app.session.doc.canvases.iter().find(|c| c.id == id).is_some_and(|c| c.visible);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! The Point Clouds folder of the Browser (#5151): an eye per cloud and for the folder, a menu,
|
||||
//! and the Edit Point Cloud card (the file's units, the size of the dots, how they are coloured:
|
||||
//! the file's colours, one colour, or by height between two heights), whose live edits of one
|
||||
//! opening are one undo step.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::doc::point_cloud::{ColorBy, PLAIN, PointCloud, UNITS, byte};
|
||||
use solidcraft_engine::view::POINT_CLOUDS_FOLDER;
|
||||
|
||||
use super::{Actions, Row, draw_row, is_open, panel_pos, toggle, toggle_folder};
|
||||
use crate::SolveApp;
|
||||
use crate::context_menu::{Item, Target};
|
||||
use crate::ui_kit;
|
||||
|
||||
/// The Edit Point Cloud card: the cloud, the undo depth when it opened, and the automatic
|
||||
/// height range of the points it was worked out for (it sorts a sample of them).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CloudPanel {
|
||||
pub cloud: u64,
|
||||
pub(crate) depth: usize,
|
||||
auto: Option<(usize, [u64; 16], Option<[f64; 2]>)>,
|
||||
}
|
||||
|
||||
impl CloudPanel {
|
||||
pub fn new(app: &SolveApp, cloud: u64) -> Self {
|
||||
CloudPanel { cloud, depth: app.session.undo.len(), auto: None }
|
||||
}
|
||||
/// The cloud's automatic height range, worked out again only when its points or placement
|
||||
/// change.
|
||||
fn auto_range(&mut self, c: &PointCloud) -> Option<[f64; 2]> {
|
||||
let key = (std::sync::Arc::as_ptr(c.points.values()).cast::<f32>() as usize, std::array::from_fn(|i| c.transform[i / 4][i % 4].to_bits()));
|
||||
match &self.auto {
|
||||
Some((p, t, r)) if (*p, *t) == key => *r,
|
||||
_ => {
|
||||
let r = c.auto_height_range();
|
||||
self.auto = Some((key.0, key.1, r));
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ways the card colours the points, as the dropdown lists them.
|
||||
const COLOR_BY: [(ColorBy, &str); 3] = [(ColorBy::File, "File Colors"), (ColorBy::Single, "Single Color"), (ColorBy::Height, "Height")];
|
||||
|
||||
pub(super) fn rows(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize, acts: &mut Actions) {
|
||||
let clouds: Vec<(u64, String, bool)> = app.session.doc.point_clouds.iter().map(|c| (c.id, c.name.clone(), c.visible)).collect();
|
||||
if clouds.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = "point_clouds";
|
||||
let open = is_open(app, key, true);
|
||||
// The folder's eye is its own: each cloud keeps its eye while the folder hides them all.
|
||||
let shown = !app.session.folder_hidden(POINT_CLOUDS_FOLDER);
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(key),
|
||||
&Row { depth, fold: Some(open), eye: Some(shown), icon: "folder", label: "Point Clouds", dim: !shown, ..Default::default() },
|
||||
);
|
||||
if r.fold || r.clicked {
|
||||
toggle(app, key, true);
|
||||
}
|
||||
if r.eye {
|
||||
toggle_folder(app, POINT_CLOUDS_FOLDER);
|
||||
}
|
||||
if !open {
|
||||
return;
|
||||
}
|
||||
for (id, name, visible) in clouds {
|
||||
let r = draw_row(
|
||||
ui,
|
||||
ui.id().with(("point_cloud", id)),
|
||||
&Row { depth: depth + 1, eye: Some(visible), icon: "point", label: &name, name: true, dim: !visible || !shown, ..Default::default() },
|
||||
);
|
||||
if r.eye {
|
||||
let _ = app.run("pointcloud.edit", json!({ "cloud": id, "visible": !visible }));
|
||||
}
|
||||
if r.double {
|
||||
app.tree.cloud_panel = Some(CloudPanel::new(app, id));
|
||||
}
|
||||
if let Some(p) = r.secondary {
|
||||
acts.menu = Some((p, Target::PointCloud { id }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu of a point cloud.
|
||||
pub fn items(app: &SolveApp, id: u64) -> Vec<Item> {
|
||||
let visible = app.session.doc.point_clouds.iter().find(|c| c.id == id).is_some_and(|c| c.visible);
|
||||
vec![
|
||||
Item::action("ui.editPointCloud", "Edit Point Cloud", "edit").with(json!({ "cloud": id })),
|
||||
Item::sep(),
|
||||
Item::action("pointcloud.delete", "Delete", "delete").with(json!({ "cloud": id })),
|
||||
Item::sep(),
|
||||
Item::action("pointcloud.edit", "Show/Hide", "eye").key("V").with(json!({ "cloud": id, "visible": !visible })),
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut m) = app.tree.cloud_panel.clone() else { return };
|
||||
let Some(c) = app.session.doc.point_clouds.iter().find(|c| c.id == m.cloud).cloned() else {
|
||||
app.tree.cloud_panel = None;
|
||||
return;
|
||||
};
|
||||
let mut edit: Option<Value> = None;
|
||||
let title = crate::i18n::format("browser.edit_point_cloud_title", &[("name", &c.name)]);
|
||||
let (units, decimals) = (app.session.doc.units.clone(), app.preferences.length_decimals);
|
||||
let auto = if c.color_by == ColorBy::Height { m.auto_range(&c) } else { None };
|
||||
let at = panel_pos(app);
|
||||
let (closed, action) = ui_kit::Window::new(egui::Id::new("sc_point_cloud_panel"), "point", &title)
|
||||
.placed(egui::Align2::LEFT_TOP, at)
|
||||
.fields()
|
||||
.actions("", crate::i18n::tr("ui.close"), None)
|
||||
.show(ctx, true, |ui| {
|
||||
ui_kit::row(ui, crate::i18n::tr_label("Points"), |ui| ui.label(c.len().to_string()));
|
||||
let names: Vec<&str> = UNITS.iter().map(|(u, _)| *u).collect();
|
||||
let was = names.iter().position(|u| *u == c.units).unwrap_or(0);
|
||||
let mut unit = was;
|
||||
// A dropdown's response doesn't report the pick: compare the index.
|
||||
ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_units"), |ui| ui_kit::dropdown(ui, "point_cloud_units", &mut unit, &names));
|
||||
if unit != was
|
||||
&& let Some(u) = names.get(unit)
|
||||
{
|
||||
edit = Some(json!({ "cloud": c.id, "units": u }));
|
||||
}
|
||||
let mut size = c.point_size;
|
||||
if ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_size"), |ui| ui_kit::slider(ui, &mut size, 0.5..=16.0, Default::default())).changed() {
|
||||
edit = Some(json!({ "cloud": c.id, "point_size": size }));
|
||||
}
|
||||
let labels: Vec<&str> = COLOR_BY.iter().map(|(_, l)| *l).collect();
|
||||
let was = COLOR_BY.iter().position(|(b, _)| *b == c.color_by).unwrap_or(0);
|
||||
let mut by = was;
|
||||
ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_color_by"), |ui| ui_kit::dropdown(ui, "point_cloud_color_by", &mut by, &labels));
|
||||
if by != was
|
||||
&& let Some((b, _)) = COLOR_BY.get(by)
|
||||
{
|
||||
edit = Some(json!({ "cloud": c.id, "color_by": b.name() }));
|
||||
}
|
||||
match c.color_by {
|
||||
ColorBy::File if c.colors.is_empty() => {
|
||||
ui_kit::row(ui, "", |ui| ui.weak(crate::i18n::tr("ui.point_cloud_no_file_colors")));
|
||||
}
|
||||
ColorBy::File => {}
|
||||
ColorBy::Single => {
|
||||
let mut rgb = c.color.unwrap_or(PLAIN).map(byte);
|
||||
if ui_kit::row(ui, crate::i18n::tr_label("Colour"), |ui| ui_kit::color_field(ui, &mut rgb)).changed() {
|
||||
edit = Some(json!({ "cloud": c.id, "color": rgb.map(|v| f64::from(v) / 255.0) }));
|
||||
}
|
||||
}
|
||||
ColorBy::Height => {
|
||||
let automatic = c.height_range.is_none();
|
||||
if ui_kit::checkbox_row(ui, "point_cloud_auto_range", crate::i18n::tr("ui.point_cloud_auto_range"), automatic) {
|
||||
// Off: start from the range worked out, which the fields then show.
|
||||
edit = Some(json!({ "cloud": c.id, "height_range": if automatic { json!(auto) } else { Value::Null } }));
|
||||
}
|
||||
if let Some([mut lo, mut hi]) = c.height_range.or(auto) {
|
||||
let mut changed = false;
|
||||
ui.add_enabled_ui(!automatic, |ui| {
|
||||
changed |= ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_height_low"), |ui| {
|
||||
ui_kit::length_field(ui, &mut lo, &units, decimals)
|
||||
})
|
||||
.changed();
|
||||
changed |= ui_kit::row(ui, crate::i18n::tr("ui.point_cloud_height_high"), |ui| {
|
||||
ui_kit::length_field(ui, &mut hi, &units, decimals)
|
||||
})
|
||||
.changed();
|
||||
});
|
||||
// The two heights stay apart, lowest first.
|
||||
if changed && !automatic {
|
||||
let hi = hi.max(lo + 1e-3);
|
||||
edit = Some(json!({ "cloud": c.id, "height_range": [lo, hi] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(p) = edit
|
||||
&& app.run("pointcloud.edit", p).is_ok()
|
||||
{
|
||||
// One undo step for the whole edit.
|
||||
if app.session.undo.len() > m.depth + 1 {
|
||||
let keep = app.session.undo.get(m.depth).cloned();
|
||||
app.session.undo.truncate(m.depth);
|
||||
app.session.undo.extend(keep);
|
||||
}
|
||||
}
|
||||
app.tree.cloud_panel = if closed || action == ui_kit::Action::Cancel { None } else { Some(m) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn a_cloud_has_a_menu_and_a_card_that_closes() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-browser-cloud-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("scan.xyz");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"0 0 0 255 0 0
|
||||
1 0 0 0 255 0
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
let id = h.app.run("pointcloud.insert", json!({"path": path.to_string_lossy()})).unwrap()["cloud"].as_u64().unwrap();
|
||||
let items = super::items(&h.app, id);
|
||||
assert!(items.iter().any(|i| i.id == "pointcloud.delete"));
|
||||
h.app.tree.cloud_panel = Some(super::CloudPanel::new(&h.app, id));
|
||||
h.frames(4);
|
||||
assert!(h.widget("Close").is_some());
|
||||
// Each way of colouring shows its own rows.
|
||||
for by in ["single", "height", "file"] {
|
||||
h.app.run("pointcloud.edit", json!({"cloud": id, "color_by": by})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
h.app.run("pointcloud.edit", json!({"cloud": id, "color_by": "height"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.widget(crate::i18n::tr("ui.point_cloud_auto_range")).is_some());
|
||||
h.step(&json!({"widget": "Close"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.tree.cloud_panel.is_none());
|
||||
assert_eq!(h.app.session.doc.point_clouds.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub const DOCUMENTED: &[&str] = &[
|
||||
"face.delete",
|
||||
"material.assign",
|
||||
"parameters.change",
|
||||
"pointcloud.insert",
|
||||
"solid.align",
|
||||
"solid.chamfer",
|
||||
"solid.combine",
|
||||
|
||||
@@ -80,6 +80,10 @@ pub enum Target {
|
||||
Canvas {
|
||||
id: u64,
|
||||
},
|
||||
/// A point cloud.
|
||||
PointCloud {
|
||||
id: u64,
|
||||
},
|
||||
/// The Section Analysis in the browser's Analysis folder.
|
||||
Section,
|
||||
/// A component's Bodies, Sketches or Construction folder, under the design (`scope` `c0`)
|
||||
@@ -377,6 +381,7 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec<Item> {
|
||||
Target::Group { id, scope } => crate::browser::group_items(app, *id, scope),
|
||||
Target::Folder { component, folder, scope } => crate::browser::folder_items(app, *component, folder, scope),
|
||||
Target::Canvas { id } => crate::browser::canvas_items(app, *id),
|
||||
Target::PointCloud { id } => crate::browser::point_cloud_items(app, *id),
|
||||
Target::Origin => crate::browser::origin_items(app),
|
||||
Target::NamedViews => vec![act("ui.newView", "New Named View", "perspective")],
|
||||
Target::Section => crate::browser::section_items(app),
|
||||
@@ -1607,6 +1612,11 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
"ui.editPointCloud" => {
|
||||
if let Some(c) = id_of(&p, "cloud") {
|
||||
app.tree.cloud_panel = Some(crate::browser::CloudPanel::new(app, c));
|
||||
}
|
||||
}
|
||||
"ui.editCanvas" | "ui.calibrateCanvas" => {
|
||||
if let Some(c) = id_of(&p, "canvas") {
|
||||
app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, c, item.id == "ui.calibrateCanvas"));
|
||||
|
||||
@@ -270,7 +270,7 @@ const MAX_BATCH: usize = 10_000;
|
||||
/// Commands that load a file: through `engine.execute` they go to the app's file loader
|
||||
/// (`file_load::control`: off the window thread, with progress and Cancel), which a batch would
|
||||
/// bypass.
|
||||
const LOADS_A_FILE: [&str; 3] = ["doc.open", "file.insert_step", "file.insert_mesh"];
|
||||
const LOADS_A_FILE: [&str; 4] = ["doc.open", "file.insert_step", "file.insert_mesh", "pointcloud.insert"];
|
||||
|
||||
/// Edit in Place and Finish Edit go to another design's tab: a batch's rollback, or a script's
|
||||
/// later steps, would land in the wrong design.
|
||||
|
||||
@@ -85,6 +85,7 @@ impl Request {
|
||||
match &self.kind {
|
||||
Kind::Open => ("doc.open", self.params()),
|
||||
Kind::Insert if solidcraft_engine::io::is_mesh_path(&self.path) => ("file.insert_mesh", self.params()),
|
||||
Kind::Insert if solidcraft_engine::io::is_point_cloud_path(&self.path) => ("pointcloud.insert", self.params()),
|
||||
Kind::Insert => (insert_command(&self.path), self.params()),
|
||||
Kind::Recover { id, dir } => ("doc.recover", json!({"id": id, "dir": dir})),
|
||||
Kind::ExportDxf => ("file.export_view_dxf", Value::Object(self.options.clone())),
|
||||
@@ -346,7 +347,7 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
|
||||
let command = req.params.get("command").or(req.params.get("id")).and_then(Value::as_str);
|
||||
let kind = match command {
|
||||
Some("doc.open") => Kind::Open,
|
||||
Some("file.insert_step" | "file.insert_mesh") => Kind::Insert,
|
||||
Some("file.insert_step" | "file.insert_mesh" | "pointcloud.insert") => Kind::Insert,
|
||||
Some("file.export_view_dxf") => Kind::ExportDxf,
|
||||
_ => return Some(req),
|
||||
};
|
||||
@@ -1286,6 +1287,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_cloud_insert_keeps_all_options_on_the_worker_request() {
|
||||
let mut request = Request::new("scan.PLY", Kind::Insert);
|
||||
request.options =
|
||||
json!({"units": "m", "max_points": 1000, "color_by": "height", "height_range": [1, 20], "name": "Scan"}).as_object().unwrap().clone();
|
||||
let (command, params) = request.command();
|
||||
assert_eq!(command, "pointcloud.insert");
|
||||
assert_eq!(
|
||||
params,
|
||||
json!({"path": "scan.PLY", "units": "m", "max_points": 1000, "color_by": "height", "height_range": [1, 20], "name": "Scan"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dxf_export_progress_keeps_frames_alive_and_never_adopts_a_worker_document() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -8,9 +8,10 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui_wgpu::wgpu;
|
||||
pub use solidcraft_gpu::view::{
|
||||
AccessMap, BlockBuilder, BlockBytes, BlockTris, DrawStats, DrawStatsHandle, Frame, GpuImage, GpuScene, GpuTarget, INSTANCE_SIZE, InstanceLook,
|
||||
InstancedMesh, LINE_SIZE, LOD_PX, MAX_PALETTE, PBR_SIZE, Palette, PaletteIndex, PlannedBody, Renderer, SHARED_SIZE, SceneBlock, SceneSlot,
|
||||
SharedTris, TRI_SIZE, TriClass, extend_runs, level_for, lifted_width, placement_scale, runs_within, supersampled_size,
|
||||
AccessMap, BlockBuilder, BlockBytes, BlockTris, CLOUD_POINT_SIZE, DrawStats, DrawStatsHandle, Frame, GpuImage, GpuPoints, GpuScene, GpuTarget,
|
||||
INSTANCE_SIZE, InstanceLook, InstancedMesh, LINE_SIZE, LOD_PX, MAX_PALETTE, PBR_SIZE, Palette, PaletteIndex, PlannedBody, Renderer, SHARED_SIZE,
|
||||
SceneBlock, SceneSlot, SharedTris, TRI_SIZE, TriClass, cloud_point, extend_runs, level_for, lifted_width, placement_scale, runs_within,
|
||||
supersampled_size,
|
||||
};
|
||||
|
||||
/// A frame of the viewport as an egui paint callback.
|
||||
@@ -424,6 +425,7 @@ fn offscreen(
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis,
|
||||
analysis_colors,
|
||||
access: None,
|
||||
|
||||
@@ -1025,11 +1025,13 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, action: FileAction) {
|
||||
app.open_path_async(&path);
|
||||
}
|
||||
}),
|
||||
FileAction::Import => app.pick_open_for(&["step", "stp", "iges", "igs", "stl", "obj", "3mf", "gltf", "glb"], |app, path| {
|
||||
if let Some(path) = path {
|
||||
app.open_path_async(&path);
|
||||
}
|
||||
}),
|
||||
FileAction::Import => {
|
||||
app.pick_open_for(&["step", "stp", "iges", "igs", "stl", "obj", "3mf", "gltf", "glb", "ply", "xyz", "pts"], |app, path| {
|
||||
if let Some(path) = path {
|
||||
app.open_path_async(&path);
|
||||
}
|
||||
})
|
||||
}
|
||||
FileAction::Save => {
|
||||
app.home.open = false;
|
||||
crate::toolbar::save(app);
|
||||
|
||||
@@ -196,6 +196,7 @@ pub mod params_dialog;
|
||||
pub(crate) mod pick_index;
|
||||
pub(crate) mod place_gizmo;
|
||||
pub(crate) mod plugins;
|
||||
pub mod point_clouds;
|
||||
pub mod prefs;
|
||||
pub mod prefs_notice;
|
||||
mod prefs_search;
|
||||
@@ -1496,7 +1497,7 @@ impl SolveApp {
|
||||
linked_source::start_selected(self);
|
||||
return;
|
||||
}
|
||||
if matches!(id, "file.insert_step" | "file.insert_mesh") {
|
||||
if matches!(id, "file.insert_step" | "file.insert_mesh" | "pointcloud.insert") {
|
||||
self.pick_open(|app, path| {
|
||||
if let Some(path) = path {
|
||||
app.insert_path_async(&path);
|
||||
@@ -1947,7 +1948,7 @@ impl SolveApp {
|
||||
|
||||
/// Insert CAD bodies, meshes or sketch drawing geometry into the current design.
|
||||
pub fn insert_path(&mut self, path: &str) {
|
||||
let cmd = file_load::insert_command(path);
|
||||
let cmd = if solidcraft_engine::io::is_point_cloud_path(path) { "pointcloud.insert" } else { file_load::insert_command(path) };
|
||||
let before: Vec<String> = self.session.world_state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
if let Ok(r) = self.run(cmd, json!({ "path": path })) {
|
||||
self.fit_view();
|
||||
@@ -1958,12 +1959,14 @@ impl SolveApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dropped CAD, mesh or drawing file joins a design that has features; otherwise it opens.
|
||||
/// A dropped CAD, mesh, drawing or point cloud (PLY, XYZ, PTS) file joins a design that has
|
||||
/// features or point clouds; otherwise it opens.
|
||||
pub fn drop_path(&mut self, path: &str) {
|
||||
let import = solidcraft_engine::io::is_step_path(path)
|
||||
|| solidcraft_engine::io::is_mesh_path(path)
|
||||
|| solidcraft_engine::io::is_point_cloud_path(path)
|
||||
|| solidcraft_engine::cmd::drawing_import_command(path).is_some();
|
||||
if import && !self.session.doc.features.is_empty() {
|
||||
if import && (!self.session.doc.features.is_empty() || !self.session.doc.point_clouds.is_empty()) {
|
||||
self.insert_path_async(path);
|
||||
} else {
|
||||
self.open_path_async(path);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Point clouds (#5151): with the GPU viewport their points are dots in the 3D pass (depth-tested
|
||||
//! with the model); the CPU viewport draws some of them over its image.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Painter, Shape};
|
||||
use solidcraft_engine::doc::point_cloud::PointCloud;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::gpu::GpuPoints;
|
||||
use crate::viewport::Proj;
|
||||
|
||||
/// Most points the CPU viewport draws of all clouds together.
|
||||
const MAX_CPU_POINTS: usize = 100_000;
|
||||
|
||||
/// What a cloud's dots are made from: they are made again only when one of these changes.
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Key {
|
||||
points: usize,
|
||||
colors: usize,
|
||||
len: usize,
|
||||
origin: [u64; 3],
|
||||
transform: [u64; 16],
|
||||
size: u64,
|
||||
color_by: solidcraft_engine::doc::point_cloud::ColorBy,
|
||||
color: Option<[u32; 3]>,
|
||||
height_range: Option<[u64; 2]>,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
fn of(c: &PointCloud) -> Key {
|
||||
Key {
|
||||
points: Arc::as_ptr(c.points.values()).cast::<f32>() as usize,
|
||||
colors: Arc::as_ptr(&c.colors.0).cast::<u8>() as usize,
|
||||
len: c.len(),
|
||||
origin: [c.origin.x, c.origin.y, c.origin.z].map(f64::to_bits),
|
||||
transform: std::array::from_fn(|i| c.transform[i / 4][i % 4].to_bits()),
|
||||
size: c.point_size.to_bits(),
|
||||
color_by: c.color_by,
|
||||
color: c.color.map(|k| k.map(f32::to_bits)),
|
||||
height_range: c.height_range.map(|r| r.map(f64::to_bits)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Dots by cloud id: what they were made from, their version and bytes.
|
||||
static DOTS: RefCell<HashMap<u64, (Key, u64, Arc<Vec<u8>>)>> = RefCell::new(HashMap::new());
|
||||
static VERSION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn dots(c: &PointCloud) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(c.len() * crate::gpu::CLOUD_POINT_SIZE);
|
||||
let size = c.point_size as f32;
|
||||
let shade = c.shade();
|
||||
for (i, p) in c.points.iter().enumerate() {
|
||||
// Placed in double precision: the cloud's origin may be far from the design's.
|
||||
let local = c.origin + solidcraft_engine::geom::Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]));
|
||||
let w = solidcraft_engine::doc::apply_point(&c.transform, local);
|
||||
crate::gpu::cloud_point(&mut out, [w.x as f32, w.y as f32, w.z as f32], shade(i), size);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The shown point clouds as GPU dots.
|
||||
pub fn gpu_points(app: &SolveApp) -> Vec<GpuPoints> {
|
||||
let doc = &app.session.doc;
|
||||
DOTS.with(|m| {
|
||||
let mut m = m.borrow_mut();
|
||||
m.retain(|id, _| doc.point_clouds.iter().any(|c| c.id == *id));
|
||||
solidcraft_engine::view::shown_point_clouds(&app.session)
|
||||
.map(|c| {
|
||||
let key = Key::of(c);
|
||||
if let Some((k, version, bytes)) = m.get(&c.id)
|
||||
&& *k == key
|
||||
{
|
||||
return GpuPoints { id: c.id, version: *version, bytes: bytes.clone() };
|
||||
}
|
||||
let version = VERSION.with(|v| {
|
||||
v.set(v.get() + 1);
|
||||
v.get()
|
||||
});
|
||||
let bytes = Arc::new(dots(c));
|
||||
m.insert(c.id, (key, version, bytes.clone()));
|
||||
GpuPoints { id: c.id, version, bytes }
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// CPU viewport only: some of each shown cloud's points over the rendered model.
|
||||
pub fn show(app: &SolveApp, painter: &Painter, proj: &Proj) {
|
||||
if app.viewport.gpu.is_some() {
|
||||
return;
|
||||
}
|
||||
let clouds: Vec<&PointCloud> = solidcraft_engine::view::shown_point_clouds(&app.session).collect();
|
||||
let total: usize = clouds.iter().map(|c| c.len()).sum();
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
let stride = total.div_ceil(MAX_CPU_POINTS).max(1);
|
||||
let mut shapes = Vec::new();
|
||||
for c in clouds {
|
||||
let r = (c.point_size as f32 / painter.ctx().pixels_per_point() * 0.5).max(0.5);
|
||||
let shade = c.shade();
|
||||
for i in (0..c.len()).step_by(stride) {
|
||||
let Some(p) = c.world_point(i) else { continue };
|
||||
let Some(q) = proj.to_screen(p) else { continue };
|
||||
shapes.push(Shape::rect_filled(egui::Rect::from_center_size(q, egui::vec2(r * 2.0, r * 2.0)), 0.0, crate::theme::model_color(shade(i))));
|
||||
}
|
||||
}
|
||||
painter.extend(shapes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dots_follow_the_cloud_and_are_made_again_only_when_it_changes() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-ui-cloud-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("two.xyz");
|
||||
std::fs::write(&path, "0 0 0 255 0 0\n10 0 0 0 255 0\n").unwrap();
|
||||
let r = h.app.run("pointcloud.insert", serde_json::json!({"path": path.to_string_lossy(), "point_size": 3})).unwrap();
|
||||
let id = r["cloud"].as_u64().unwrap();
|
||||
h.app.run("browser.collapse_all", serde_json::json!({})).unwrap();
|
||||
assert!(!crate::browser::is_open(&h.app, "point_clouds", true));
|
||||
h.app.run("browser.expand_all", serde_json::json!({})).unwrap();
|
||||
assert!(crate::browser::is_open(&h.app, "point_clouds", true));
|
||||
let first = gpu_points(&h.app);
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(first[0].bytes.len(), 2 * crate::gpu::CLOUD_POINT_SIZE);
|
||||
// The second point: at x = 10, green, 3 px (24 eighths).
|
||||
assert_eq!(&first[0].bytes[16..20], &10f32.to_le_bytes());
|
||||
assert_eq!(&first[0].bytes[28..32], &[0, 255, 0, 24]);
|
||||
assert_eq!(gpu_points(&h.app)[0].version, first[0].version, "unchanged: the same dots");
|
||||
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color": [0, 0, 1]})).unwrap();
|
||||
let blue = gpu_points(&h.app);
|
||||
assert_ne!(blue[0].version, first[0].version);
|
||||
assert_eq!(&blue[0].bytes[12..15], &[0, 0, 255]);
|
||||
// By height across x = 0…10 (the cloud turned so x is up): low end blue, high end red.
|
||||
let up = serde_json::json!([0, 0, -1, 0, 0, 1, 0, 0, 1, 0, 0, 0]);
|
||||
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "color_by": "height", "matrix": up})).unwrap();
|
||||
let ramp = gpu_points(&h.app);
|
||||
let ramp_of = solidcraft_engine::doc::point_cloud::height_ramp;
|
||||
assert_eq!((&ramp[0].bytes[12..15], &ramp[0].bytes[28..31]), (&ramp_of(0.0)[..], &ramp_of(1.0)[..]));
|
||||
// Hidden, alone or with its folder: nothing drawn; the view frames it while shown.
|
||||
assert!(!solidcraft_engine::view::bounds(&h.app.session).is_empty());
|
||||
let first_bounds = crate::viewport::model_bounds(&h.app);
|
||||
assert!(!first_bounds.is_empty());
|
||||
assert_eq!(crate::viewport::model_bounds(&h.app), first_bounds, "cached sketch bounds keep the point cloud extent");
|
||||
h.app.run("pointcloud.edit", serde_json::json!({"cloud": id, "visible": false})).unwrap();
|
||||
assert!(gpu_points(&h.app).is_empty());
|
||||
assert!(solidcraft_engine::view::bounds(&h.app.session).is_empty());
|
||||
assert!(crate::viewport::model_bounds(&h.app).is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -1869,6 +1869,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: [0.0; 36],
|
||||
access: None,
|
||||
|
||||
@@ -281,6 +281,7 @@ pub(crate) fn view_frame(
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: crate::gpu::analysis_colors(&t.analysis),
|
||||
access: None,
|
||||
|
||||
@@ -493,6 +493,11 @@ pub fn set_vision(mode: ColorVision) {
|
||||
VISION.with(|v| v.set(mode));
|
||||
}
|
||||
|
||||
/// An sRGB colour carried by model data, including file colours and point-cloud shading.
|
||||
pub(crate) const fn model_color(rgb: [u8; 3]) -> Color32 {
|
||||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
|
||||
/// An sRGB colour given as 0..1 (the analysis colours), for drawing in the UI.
|
||||
pub fn srgb(c: [f32; 3]) -> Color32 {
|
||||
let byte = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
|
||||
@@ -669,6 +669,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
FileAction::Command("file.insert_mesh").run(app);
|
||||
close = true;
|
||||
}
|
||||
if item_if(ui, &format!("{}…", app.tr_label("Insert Point Cloud")), "", "import", shown) {
|
||||
app.start("pointcloud.insert");
|
||||
close = true;
|
||||
}
|
||||
if item_if(ui, &format!("{}…", app.tr_label("Insert Design")), "", "component", shown) {
|
||||
FileAction::Command("component.insert_design").run(app);
|
||||
close = true;
|
||||
|
||||
@@ -1923,6 +1923,11 @@ pub fn model_bounds(app: &SolveApp) -> solidcraft_engine::geom::Aabb3 {
|
||||
let st = crate::open_meshing::shown_world(app);
|
||||
let bodies = body_bounds(app, &st).iter().fold(solidcraft_engine::geom::Aabb3::EMPTY, |a, (_, x)| a.union(x));
|
||||
let mut b = bodies.union(&sketch_bounds(app, &st));
|
||||
for c in solidcraft_engine::view::shown_point_clouds(&app.session) {
|
||||
for p in c.world_corners().into_iter().flatten() {
|
||||
b.add(p);
|
||||
}
|
||||
}
|
||||
for point in crate::sketch_attachment_ui::guide_lines(app).into_iter().flatten() {
|
||||
b.add(point);
|
||||
}
|
||||
@@ -4016,6 +4021,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
back: proj.cam.back().to_f32(),
|
||||
size_px,
|
||||
images: crate::ref_images::gpu_images(app),
|
||||
points: crate::point_clouds::gpu_points(app),
|
||||
analysis: analysis_uniform(app),
|
||||
analysis_colors: crate::gpu::analysis_colors(&t.analysis),
|
||||
access: crate::access::map(app),
|
||||
@@ -4524,6 +4530,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
crate::preview::paint_sketches(app, &painter, &proj);
|
||||
crate::ref_images::show(app, ui, &painter, &proj);
|
||||
crate::point_clouds::show(app, &painter, &proj);
|
||||
crate::dialogs_assembly::origins(app, &painter, &proj);
|
||||
crate::dialogs_assembly::joint_glyphs(app, &painter, &proj);
|
||||
overlays(app, &painter, &proj);
|
||||
@@ -7147,6 +7154,7 @@ mod tests {
|
||||
clip: [0.0; 4],
|
||||
cap: [0.0; 4],
|
||||
images: Vec::new(),
|
||||
points: Vec::new(),
|
||||
analysis: [0.0; 8],
|
||||
analysis_colors: crate::gpu::analysis_colors(&Tokens::get().analysis),
|
||||
access: None,
|
||||
|
||||
@@ -171,7 +171,7 @@ pub const SECTIONS: &[Section] = &[
|
||||
tab: "INSERT",
|
||||
panel: "FILES",
|
||||
promoted: 3,
|
||||
ids: &["file.insert_step", "file.insert_mesh", "component.insert_design", "file.save_mesh", "file.send_to_slicer"],
|
||||
ids: &["file.insert_step", "file.insert_mesh", "pointcloud.insert", "component.insert_design", "file.save_mesh", "file.send_to_slicer"],
|
||||
},
|
||||
Section { tab: "INSERT", panel: "IMAGES", promoted: 3, ids: &["canvas.insert", "canvas.decal", "canvas.edit"] },
|
||||
Section { tab: "INSERT", panel: "2D", promoted: 2, ids: &["sketch.insert_dxf", "sketch.insert_svg"] },
|
||||
@@ -424,6 +424,7 @@ pub const RIBBON_LABELS: &[(&str, &str)] = &[
|
||||
("parts.resize", "Resize Part"),
|
||||
("plastic.assign_rule", "Assign Rule"),
|
||||
("plastic.manage_rules", "Plastic Rules"),
|
||||
("pointcloud.insert", "Point Clouds"),
|
||||
("render.scene", "Scene"),
|
||||
("sheet.export_bend_table", "Bend Table"),
|
||||
("sheet.export_dxf", "Export DXF"),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Point clouds
|
||||
|
||||
A point cloud is scanned points kept in the design as a reference to model from (#5151): a
|
||||
lidar or SLAM map, a photogrammetry result, a part from a handheld scanner. It is drawn as dots
|
||||
in the viewport, depth-tested with the bodies, and never becomes a body; nothing in the timeline
|
||||
depends on it.
|
||||
|
||||
## Files
|
||||
|
||||
| Extension | What is read |
|
||||
|-----------|--------------|
|
||||
| `.ply` | ASCII, binary little-endian and binary big-endian PLY: the `vertex` element's `x`, `y`, `z` (any numeric type) and its `red`, `green`, `blue` when present (bytes, 16-bit or 0…1 floats). Other properties (normals, intensity) and elements are passed over. |
|
||||
| `.xyz`, `.pts` | Text, a point per line: `x y z`, optionally followed by `r g b` (six numbers) or by an intensity and `r g b` (seven or more, as PTS files write it). Spaces, tabs, commas or semicolons separate the numbers; `#` and `//` start comments; a PTS file's first line (its count) is skipped. |
|
||||
|
||||
A PLY file that also has faces can be inserted as a mesh body instead with **Insert Mesh**
|
||||
(`file.insert_mesh`); **Insert Point Cloud** then mentions it in its warnings.
|
||||
|
||||
Point cloud files say nothing about their length unit. `units` (`mm`, `cm`, `m`, `in`, `ft`)
|
||||
says what it is; the default is millimetres, as for every other import. Lidar and SLAM maps are
|
||||
usually in metres. The unit can be changed afterwards (Browser › Point Clouds › Edit Point Cloud,
|
||||
or `pointcloud.edit` with `units`): the points are scaled from the unit they came in as.
|
||||
|
||||
## Colours
|
||||
|
||||
**Color By** in the Edit Point Cloud card (or `color_by`) says how the points are coloured:
|
||||
|
||||
- `file` (the default): the file's colour of each point, grey when it has none.
|
||||
- `single`: one colour (`color`) for every point.
|
||||
- `height`: a ramp by design Z, blue low through cyan, green and yellow to red high. It runs
|
||||
between two heights (`height_range`, mm). By default they are worked out from the points: all
|
||||
but the lowest and highest 2 %, so a few stray points don't flatten the ramp. Turn
|
||||
**Automatic Range** off in the card to set them.
|
||||
|
||||
## Size
|
||||
|
||||
A cloud keeps at most `max_points` points (5,000,000 unless asked, at most 20,000,000); a larger
|
||||
file is thinned evenly, one point in *n*, and the result says how many were kept. Files up to
|
||||
1 GiB are read. The points are stored single precision relative to the cloud's first point (kept
|
||||
double precision), so a scan far from the origin keeps its detail; they travel inside the design
|
||||
file as a binary part (12 bytes a point, 3 more with colours).
|
||||
|
||||
## Commands
|
||||
|
||||
- `pointcloud.insert` — `path`; `name?`, `units?`, `max_points?`, `center?` (move the middle of
|
||||
its base to the origin), `point_size?` (pixels), `color_by?` (`file`, `single`, `height`),
|
||||
`color?` (`[r, g, b]` 0…1; alone it means `single`, `null` means `file`), `height_range?`
|
||||
(`[low, high]` mm, or `null` for the automatic range).
|
||||
- `pointcloud.edit` — `cloud` (id or name); `name?`, `visible?`, `point_size?`, `color_by?`,
|
||||
`color?`, `height_range?`, `units?`, `matrix?` (a rigid placement, as `occurrence.move` takes it).
|
||||
- `pointcloud.delete`, `pointcloud.list`.
|
||||
- `doc.open` opens a point cloud file as a new design holding it; dropping one on the window
|
||||
adds it to the open design.
|
||||
|
||||
## Example
|
||||
|
||||
```json
|
||||
{"command": "pointcloud.insert", "params": {"path": "glim-points.ply", "units": "m", "color_by": "height"}}
|
||||
```
|
||||
Reference in New Issue
Block a user